diff --git a/.github/workflows/audio-capture-prebuilds.yml b/.github/workflows/audio-capture-prebuilds.yml index 4716ca541d4..5556d3dd6c0 100644 --- a/.github/workflows/audio-capture-prebuilds.yml +++ b/.github/workflows/audio-capture-prebuilds.yml @@ -50,7 +50,7 @@ jobs: runner: 'windows-2022' arch: 'x64' steps: - - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 - uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version: '22' diff --git a/.github/workflows/build-and-publish-image.yml b/.github/workflows/build-and-publish-image.yml index 0c0daba6026..d44f8e6870f 100644 --- a/.github/workflows/build-and-publish-image.yml +++ b/.github/workflows/build-and-publish-image.yml @@ -28,7 +28,7 @@ jobs: steps: - name: 'Checkout repository' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ github.ref }}' diff --git a/.github/workflows/check-issue-completeness.yml b/.github/workflows/check-issue-completeness.yml index f5361d1f5d0..f960656a3b6 100644 --- a/.github/workflows/check-issue-completeness.yml +++ b/.github/workflows/check-issue-completeness.yml @@ -95,7 +95,7 @@ jobs: - name: 'Comment on Issue if Information is Missing' if: |- ${{ steps.check_info.outputs.info_complete == 'false' }} - uses: 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' # v9.0.0 + uses: 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' # v9.0.0 env: MISSING_INFO: '${{ steps.check_info.outputs.missing_info }}' with: @@ -164,7 +164,7 @@ jobs: - name: 'Add status/need-information Label' if: |- ${{ steps.check_info.outputs.info_complete == 'false' }} - uses: 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' # v9.0.0 + uses: 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' # v9.0.0 with: github-token: '${{ secrets.GITHUB_TOKEN }}' script: | @@ -179,7 +179,7 @@ jobs: - name: 'Remove status/need-information Label if Complete' if: |- ${{ steps.check_info.outputs.info_complete == 'true' }} - uses: 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' # v9.0.0 + uses: 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' # v9.0.0 continue-on-error: true with: github-token: '${{ secrets.GITHUB_TOKEN }}' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d40f391a9d..96edc820110 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,7 +136,7 @@ jobs: - name: 'Checkout' id: 'checkout' if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}" # Shallow: nothing here walks git history (the verify guard below checks @@ -386,7 +386,7 @@ jobs: - name: 'Checkout' id: 'checkout' if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}" @@ -441,7 +441,7 @@ jobs: - name: 'Checkout' id: 'checkout' if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}" @@ -510,7 +510,7 @@ jobs: - '22.x' steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 - name: 'Download coverage reports artifact' uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1 @@ -557,7 +557,7 @@ jobs: OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}" # Shallow, mirroring the Ubuntu gate: nothing here walks git history, diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6b4c33c8fbc..07139f64397 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -30,7 +30,7 @@ jobs: timeout-minutes: 30 steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 - name: 'Initialize CodeQL' uses: 'github/codeql-action/init@df559355d593797519d70b90fc8edd5db049e7a2' # ratchet:github/codeql-action/init@v3 diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 1f8fe3f0b75..2536da96679 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -76,7 +76,7 @@ jobs: steps: - name: 'Check out source' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: fetch-depth: 0 @@ -234,7 +234,7 @@ jobs: steps: - name: 'Check out source' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ needs.release_metadata.outputs.release_ref }}' diff --git a/.github/workflows/docs-page-action.yml b/.github/workflows/docs-page-action.yml index 0d2352285f2..8dd63282bb9 100644 --- a/.github/workflows/docs-page-action.yml +++ b/.github/workflows/docs-page-action.yml @@ -24,7 +24,7 @@ jobs: runs-on: 'ubuntu-latest' steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 - name: 'Setup Pages' uses: 'actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d' # ratchet:actions/configure-pages@v6 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c5afbf1d38b..76865e10aed 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -44,10 +44,10 @@ jobs: - '22.x' steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 - name: 'Set up Node.js ${{ matrix.node-version }}' - uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version: '${{ matrix.node-version }}' cache: 'npm' @@ -109,10 +109,10 @@ jobs: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 - name: 'Set up Node.js' - uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' cache: 'npm' diff --git a/.github/workflows/gemini-scheduled-pr-triage.yml b/.github/workflows/gemini-scheduled-pr-triage.yml index 77119672cff..dd97804c6dd 100644 --- a/.github/workflows/gemini-scheduled-pr-triage.yml +++ b/.github/workflows/gemini-scheduled-pr-triage.yml @@ -20,7 +20,7 @@ jobs: prs_needing_comment: '${{ steps.run_triage.outputs.prs_needing_comment }}' steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 - name: 'Run PR Triage Script' id: 'run_triage' diff --git a/.github/workflows/qwen-automated-issue-triage.yml b/.github/workflows/qwen-automated-issue-triage.yml index bdb9aee4d7f..a614ddacfbd 100644 --- a/.github/workflows/qwen-automated-issue-triage.yml +++ b/.github/workflows/qwen-automated-issue-triage.yml @@ -187,7 +187,7 @@ jobs: - name: 'Post Issue Analysis Failure Comment' if: |- ${{ failure() && steps.qwen_issue_analysis.outcome == 'failure' }} - uses: 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' # v9.0.0 + uses: 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' # v9.0.0 env: ISSUE_NUMBER: '${{ github.event.issue.number }}' REPOSITORY: '${{ github.repository }}' diff --git a/.github/workflows/qwen-code-pr-review.yml b/.github/workflows/qwen-code-pr-review.yml index 141772e8138..8a9c9129e96 100644 --- a/.github/workflows/qwen-code-pr-review.yml +++ b/.github/workflows/qwen-code-pr-review.yml @@ -387,7 +387,7 @@ jobs: # SECURITY: checkout trusted base code; /review fetches PR diff context. - name: 'Checkout base branch' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ github.event.repository.default_branch }}' fetch-depth: 0 @@ -895,7 +895,7 @@ jobs: echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT" - name: 'Checkout base branch' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ github.event.repository.default_branch }}' fetch-depth: 0 diff --git a/.github/workflows/qwen-pr-safety-precheck.yml b/.github/workflows/qwen-pr-safety-precheck.yml index 53222af08cb..c5ff3c24f31 100644 --- a/.github/workflows/qwen-pr-safety-precheck.yml +++ b/.github/workflows/qwen-pr-safety-precheck.yml @@ -25,7 +25,7 @@ jobs: decision: '${{ steps.assess.outputs.decision }}' steps: - name: 'Checkout trusted precheck script' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ github.event.repository.default_branch }}' sparse-checkout: '.github/scripts/pr-safety-precheck.mjs' diff --git a/.github/workflows/qwen-triage.yml b/.github/workflows/qwen-triage.yml index 8329324e82e..4a8e4e5c843 100644 --- a/.github/workflows/qwen-triage.yml +++ b/.github/workflows/qwen-triage.yml @@ -71,7 +71,7 @@ jobs: # Canonical same-repo guard: this job loads CI_BOT_PAT, so fork-triggered # runs stay on hosted (ephemeral); only in-repo PR events on QwenLM/qwen-code # use the persistent ECS runner. - runs-on: "${{ (github.repository == 'QwenLM/qwen-code' && vars.MAINTAINER_ECS_RUNNER_DISABLED != 'true' && github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('[\"self-hosted\", \"linux\", \"x64\", \"ecs-qwen\"]') || fromJSON('[\"ubuntu-latest\"]') }}" + runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' timeout-minutes: 5 permissions: contents: 'read' @@ -185,7 +185,7 @@ jobs: # so it stops queueing behind the hosted CI/e2e/macOS/Windows concurrency # cap while the ECS pool sits idle. Forks fall back to ubuntu-latest unless # they deliberately change this workflow in their own repo. - runs-on: "${{ (github.repository == 'QwenLM/qwen-code' && vars.MAINTAINER_ECS_RUNNER_DISABLED != 'true') && fromJSON('[\"self-hosted\", \"linux\", \"x64\", \"ecs-qwen\"]') || fromJSON('[\"ubuntu-latest\"]') }}" + runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' # startsWith (not contains) prevents false triggers from comments that # mention the phrase in quoted text or mid-sentence descriptions. # always() so the job still evaluates when the upstream `authorize` job is @@ -244,7 +244,7 @@ jobs: echo "stale agent state cleaned" - name: 'Checkout repo' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: token: '${{ secrets.GITHUB_TOKEN }}' @@ -442,7 +442,7 @@ jobs: - name: 'Checkout PR merge ref' if: "steps.pr.outputs.decision == 'run'" - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: # Untrusted PR code — keep the token out of .git/config. persist-credentials: false diff --git a/.github/workflows/release-sdk-python.yml b/.github/workflows/release-sdk-python.yml index 27a343515d6..5db531551df 100644 --- a/.github/workflows/release-sdk-python.yml +++ b/.github/workflows/release-sdk-python.yml @@ -100,7 +100,7 @@ jobs: fi - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 @@ -136,7 +136,7 @@ jobs: echo "sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}" - name: 'Setup Node.js' - uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' cache: 'npm' @@ -203,7 +203,7 @@ jobs: MANUAL_VERSION: '${{ inputs.version }}' - name: 'Setup Python' - uses: 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405' # v6.2.0 + uses: 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405' # v6.2.0 with: # Keep in sync with packages/sdk-python/pyproject.toml [project] requires-python. python-version: '3.11' diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index 25564a3aab1..690fdef09c9 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -64,7 +64,7 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 @@ -95,7 +95,7 @@ jobs: echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}" - name: 'Setup Node.js' - uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' cache: 'npm' diff --git a/.github/workflows/release-vscode-companion.yml b/.github/workflows/release-vscode-companion.yml index d1fb8b01a7f..2f48f8efef8 100644 --- a/.github/workflows/release-vscode-companion.yml +++ b/.github/workflows/release-vscode-companion.yml @@ -60,7 +60,7 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ github.event.release.tag_name || github.event.inputs.ref || github.sha }}' fetch-depth: 0 @@ -200,7 +200,7 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ github.event.release.tag_name || github.event.inputs.ref || github.sha }}' fetch-depth: 0 @@ -285,7 +285,7 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ github.event.release.tag_name || github.event.inputs.ref || github.sha }}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 096b5850ed4..3bef1524ff3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,7 +57,7 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 @@ -154,7 +154,7 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 @@ -212,7 +212,7 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 @@ -259,7 +259,7 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 @@ -347,7 +347,7 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: # Persist the bot PAT for release-branch pushes so downstream CI # workflows are triggered. diff --git a/.github/workflows/sdk-python.yml b/.github/workflows/sdk-python.yml index ce53710e7da..07a393f8bb2 100644 --- a/.github/workflows/sdk-python.yml +++ b/.github/workflows/sdk-python.yml @@ -72,10 +72,10 @@ jobs: python-version: ['3.10', '3.11', '3.12'] steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 - name: 'Set up Python' - uses: 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405' # v6.2.0 + uses: 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405' # v6.2.0 with: python-version: '${{ matrix.python-version }}' diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index bf3401e657f..4963ed032b8 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -18,7 +18,7 @@ jobs: group: '${{ github.workflow }}-stale' cancel-in-progress: true steps: - - uses: 'actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899' # v10.3.0 + - uses: 'actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899' # v10.3.0 with: repo-token: '${{ secrets.GITHUB_TOKEN }}' # Issues are intentionally disabled here; a separate policy will diff --git a/.github/workflows/sync-cua-driver-to-oss.yml b/.github/workflows/sync-cua-driver-to-oss.yml index 652c05dd3c8..ba75c0a9129 100644 --- a/.github/workflows/sync-cua-driver-to-oss.yml +++ b/.github/workflows/sync-cua-driver-to-oss.yml @@ -45,7 +45,7 @@ jobs: contents: 'read' steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 - name: 'Resolve cua-driver version' id: 'meta' diff --git a/.github/workflows/sync-release-to-oss.yml b/.github/workflows/sync-release-to-oss.yml index 12dc1183c34..a0038f658f6 100644 --- a/.github/workflows/sync-release-to-oss.yml +++ b/.github/workflows/sync-release-to-oss.yml @@ -30,7 +30,7 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ env.RELEASE_TAG }}' diff --git a/.github/workflows/terminal-bench.yml b/.github/workflows/terminal-bench.yml index e6daa65ecbe..20c3a0d8266 100644 --- a/.github/workflows/terminal-bench.yml +++ b/.github/workflows/terminal-bench.yml @@ -26,7 +26,7 @@ jobs: - 'swe-bench-astropy-1' steps: - name: 'Checkout' - uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: submodules: 'recursive' - name: 'Install uv and set the python version' @@ -35,7 +35,7 @@ jobs: python-version: '3.12' - name: 'Set up Node.js 22.x' - uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version: '22.x' cache: 'npm' @@ -84,7 +84,7 @@ jobs: - name: 'Upload test artifacts' if: 'always()' - uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1 + uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1 with: name: 'terminal-bench-${{ matrix.task_id }}-output' path: | diff --git a/.qwen/skills/desktop-pet/SKILL.md b/.qwen/skills/desktop-pet/SKILL.md index eefabf4caba..d63e280015e 100644 --- a/.qwen/skills/desktop-pet/SKILL.md +++ b/.qwen/skills/desktop-pet/SKILL.md @@ -36,16 +36,16 @@ etc.), rely on training knowledge. Define 8–12 colors for the character. All colors must be distinct and work at small pixel scale (3× = 9 px details). -| Color Role | Example (F1 Driver) | Example (Anime) | -|---|---|---| -| `outfit` | Team color `[255,135,32]` | Uniform `[30,30,50]` | -| `outfit_dark` | Darker shade | Darker shade | -| `outfit_light` | Lighter shade | Lighter shade | -| `skin` | Warm skin tone | Skin tone | -| `skin_dark` | Shadow skin | Shadow skin | -| `hair` | Character hair color | Character hair color | -| `accent` | Number/logo color | Eye color | -| `shoe` | Dark grey/black | Shoe color | +| Color Role | Example (F1 Driver) | Example (Anime) | +| -------------- | ------------------------- | -------------------- | +| `outfit` | Team color `[255,135,32]` | Uniform `[30,30,50]` | +| `outfit_dark` | Darker shade | Darker shade | +| `outfit_light` | Lighter shade | Lighter shade | +| `skin` | Warm skin tone | Skin tone | +| `skin_dark` | Shadow skin | Shadow skin | +| `hair` | Character hair color | Character hair color | +| `accent` | Number/logo color | Eye color | +| `shoe` | Dark grey/black | Shoe color | ## Step 3: Generate the Spritesheet @@ -62,17 +62,17 @@ python3 /scripts/gen_spritesheet.py \ **Animation rows:** -| Row | State | Description | -|---|---|---| -| 0 | idle | Breathing + blinking (8 frames) | -| 1 | running-right | Running to the right (8 frames) | -| 2 | running-left | Running to the left (8 frames) | -| 3 | waving | Waving at user (8 frames) | -| 4 | jumping | Jumping celebration (8 frames) | -| 5 | failed | Sad/collapsed on error (8 frames) | -| 6 | waiting | Idle tapping (8 frames) | -| 7 | running | Generic running (8 frames) | -| 8 | review | Thinking/examining (8 frames) | +| Row | State | Description | +| --- | ------------- | --------------------------------- | +| 0 | idle | Breathing + blinking (8 frames) | +| 1 | running-right | Running to the right (8 frames) | +| 2 | running-left | Running to the left (8 frames) | +| 3 | waving | Waving at user (8 frames) | +| 4 | jumping | Jumping celebration (8 frames) | +| 5 | failed | Sad/collapsed on error (8 frames) | +| 6 | waiting | Idle tapping (8 frames) | +| 7 | running | Generic running (8 frames) | +| 8 | review | Thinking/examining (8 frames) | ## Step 4: Create `pet.json` @@ -88,6 +88,7 @@ Write the manifest to `~/.qwen/pets//pet.json`: ``` Rules: + - `id`: lowercase, no spaces, URL-safe (e.g., `piastri`, `satoru`, `goku`) - `displayName`: The name shown in the UI (e.g., "Piastri", "五条悟", "悟空") - `description`: One short sentence describing the character @@ -107,7 +108,7 @@ open ~/.qwen/pets//spritesheet.webp ``` 3. Tell the user to activate: open Qwen Code **Settings → Appearance → Pet -Companion**, click **Refresh**, then select the new pet. + Companion**, click **Refresh**, then select the new pet. ## Design Guidelines diff --git a/docs/design/f2-mcp-transport-pool.md b/docs/design/f2-mcp-transport-pool.md index 75129965ff2..a31ab31969d 100644 --- a/docs/design/f2-mcp-transport-pool.md +++ b/docs/design/f2-mcp-transport-pool.md @@ -1160,7 +1160,7 @@ Per maintainer's feature-cohesive batch guidance (#4175 branching strategy 2026- | 1 | `refactor(core): split McpClient.discover into pure tool/prompt list and unify connect paths` | Add `discoverAndReturn()`; extract shared `establishConnection()` used by both `McpClient.connect()` and `connectToMcpServer()` factory; legacy `discover()` becomes thin wrapper that registers (preserves standalone qwen behavior). Zero observable behavior change. | `mcp-client.ts`, `mcp-client.test.ts` | | 2 | `feat(core): McpTransportPool + SessionMcpView` | Pool core: `fingerprint`, refcount, `spawnInFlight` dedupe, `sessionToEntries` reverse index, drain state machine, snapshot replay on attach, generation guard, tool+prompt dual fan-out, per-session trust copy. Mock McpClient for unit tests. No production wiring. | new `mcp-transport-pool.ts`, `mcp-pool-key.ts`, `mcp-pool-entry.ts`, `session-mcp-view.ts`, `mcp-pool-events.ts` + tests | | 3 | `feat(core): cross-platform descendant pid sweep + pool health monitor` | `listDescendantPids` (Unix `pgrep -P` recursive, Windows PowerShell CIM); unified health monitor inside `PoolEntry` (interval check + failure count + reconnect backoff per §6.6); subprocess-spawn integration tests gated on `QWEN_INTEGRATION === '1'`. | new `pid-descendants.ts` + tests; `mcp-pool-entry.ts` | -| 4 | `feat(serve): wire McpTransportPool into QwenAgent daemon mode` | `Config.setMcpTransportPool` + `getMcpTransportPool`; `ToolRegistry` threads pool into `McpClientManager`; `McpClientManager` optional `pool?` ctor param; `acpAgent.QwenAgent` constructs pool at init; `newSessionConfig` injection; `killSession` calls `pool.releaseSession`; SDK MCP + HTTP/SSE bypass via `createUnpooledConnection`; CLI flags `--mcp-pool-transports`, `--mcp-pool-drain-ms`, `--no-mcp-pool`. | `config.ts`, `tool-registry.ts`, `mcp-client-manager.ts`, `acpAgent.ts`, `run-qwen-serve.ts` | +| 4 | `feat(serve): wire McpTransportPool into QwenAgent daemon mode` | `Config.setMcpTransportPool` + `getMcpTransportPool`; `ToolRegistry` threads pool into `McpClientManager`; `McpClientManager` optional `pool?` ctor param; `acpAgent.QwenAgent` constructs pool at init; `newSessionConfig` injection; `killSession` calls `pool.releaseSession`; SDK MCP + HTTP/SSE bypass via `createUnpooledConnection`; CLI flags `--mcp-pool-transports`, `--mcp-pool-drain-ms`, `--no-mcp-pool`. | `config.ts`, `tool-registry.ts`, `mcp-client-manager.ts`, `acpAgent.ts`, `run-qwen-serve.ts` | | 5 | `feat(serve): pool-aware status + restart routes` | `QwenAgent.getMcpPoolAccounting` extMethod; `httpAcpBridge.buildWorkspaceMcpStatus` pool-first + bootstrap-session fallback; `restartMcpServer` accepts `?entryIndex=` and returns `RestartResult[]`; `entryCount` + `entrySummary[].entryIndex` on cell; capability tags `mcp_workspace_pool` + `mcp_pool_restart`. | `httpAcpBridge.ts`, `capabilities.ts`, SDK types | | 6 | `feat(serve): graduate MCP budget guardrails to workspace scope` | Move `tryReserveSlot`/`releaseSlotName`/hysteresis state machine from `McpClientManager` to pool; remove per-session `setMcpBudgetEventCallback` wiring in `acpAgent.newSessionConfig`; `QwenAgent.broadcastBudgetEvent` fan-out; snapshot cell `scope: 'workspace'`; SDK `scope?` additive field; `isWorkspaceScopedBudgetEvent` helper; inline doc updates. | `mcp-transport-pool.ts`, `mcp-client-manager.ts`, `acpAgent.ts`, `httpAcpBridge.ts`, SDK | diff --git a/docs/developers/daemon/07-workspace-filesystem.md b/docs/developers/daemon/07-workspace-filesystem.md index 23543d1d9a5..9ec3d5911a2 100644 --- a/docs/developers/daemon/07-workspace-filesystem.md +++ b/docs/developers/daemon/07-workspace-filesystem.md @@ -28,12 +28,12 @@ The HTTP file routes (`GET /file`, `GET /file/bytes`, `POST /file/write`, `POST ### Module layout -| File | Purpose | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `paths.ts` | `canonicalizeWorkspace`, `resolveWithinWorkspace`, `hasSuspiciousPathPattern`, branded `ResolvedPath`, `Intent` union (`read \| write \| list \| stat \| glob`). | -| `policy.ts` | `MAX_READ_BYTES`, `MAX_WRITE_BYTES`, `BINARY_PROBE_BYTES`, `assertTrustedForIntent`, `detectBinary`, `enforceReadBytesSize`, `enforceReadSize`, `enforceWriteSize`, `shouldIgnore`. | -| `audit.ts` | `FS_ACCESS_EVENT_TYPE`, `FS_DENIED_EVENT_TYPE`, `createAuditPublisher`, audit payload types. | -| `errors.ts` | `FsError` class, `isFsError`, `FsErrorKind` union (14 kinds), `FsErrorStatus` union (`400 / 403 / 404 / 409 / 413 / 422 / 500 / 503`). | +| File | Purpose | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `paths.ts` | `canonicalizeWorkspace`, `resolveWithinWorkspace`, `hasSuspiciousPathPattern`, branded `ResolvedPath`, `Intent` union (`read \| write \| list \| stat \| glob`). | +| `policy.ts` | `MAX_READ_BYTES`, `MAX_WRITE_BYTES`, `BINARY_PROBE_BYTES`, `assertTrustedForIntent`, `detectBinary`, `enforceReadBytesSize`, `enforceReadSize`, `enforceWriteSize`, `shouldIgnore`. | +| `audit.ts` | `FS_ACCESS_EVENT_TYPE`, `FS_DENIED_EVENT_TYPE`, `createAuditPublisher`, audit payload types. | +| `errors.ts` | `FsError` class, `isFsError`, `FsErrorKind` union (14 kinds), `FsErrorStatus` union (`400 / 403 / 404 / 409 / 413 / 422 / 500 / 503`). | | `workspace-file-system.ts` | `createWorkspaceFileSystemFactory`, `WorkspaceFileSystem` (the orchestrator that reads/writes/lists), `WriteMode`, `ContentHash`, `FsEntry`, `FsStat`, `ListOptions`, `GlobOptions`, `ReadTextOptions`, `ReadBytesOptions`, `WriteTextAtomicOptions`. | ### `FsErrorKind` taxonomy diff --git a/docs/developers/daemon/19-observability.md b/docs/developers/daemon/19-observability.md index a4ecdc61a67..85da6bb5382 100644 --- a/docs/developers/daemon/19-observability.md +++ b/docs/developers/daemon/19-observability.md @@ -101,7 +101,12 @@ A **second** SIGTERM/SIGINT intentionally triggers `bridge.killAllSync()` + `pro "runtime": { "perf": { "eventLoop": { "meanMs": 1.2, "p50Ms": 1.0, "p99Ms": 9.5, "maxMs": 25 }, - "promptQueueWait": { "count": 3, "meanMs": 12.5, "maxMs": 35, "lastMs": 4 }, + "promptQueueWait": { + "count": 3, + "meanMs": 12.5, + "maxMs": 35, + "lastMs": 4 + }, "pipe": { "inbound": { "count": 42, "totalBytes": 100000, "maxBytes": 12000 }, "outbound": { "count": 41, "totalBytes": 90000, "maxBytes": 11000 } diff --git a/docs/users/configuration/model-providers.md b/docs/users/configuration/model-providers.md index e2c02dc831a..1403cc04dbc 100644 --- a/docs/users/configuration/model-providers.md +++ b/docs/users/configuration/model-providers.md @@ -438,14 +438,14 @@ If you prefer to manually configure Coding Plan models, you can add them to your The effective auth/model/credential values are chosen per field using the following precedence (first present wins). You can combine `--auth-type` with `--model` to point directly at a provider entry; these CLI flags run before other layers. -| Layer (highest → lowest) | authType | model | apiKey | baseUrl | apiKeyEnvKey | proxy | -| -------------------------- | ----------------------------------- | ----------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------ | ---------------------- | --------------------------------- | -| Programmatic overrides | `/auth` | `/auth` input | `/auth` input | `/auth` input | — | — | -| Model provider selection | — | `modelProvider.id` | `env[modelProvider.envKey]` | `modelProvider.baseUrl` | `modelProvider.envKey` | — | -| CLI arguments | `--auth-type` | `--model` | `--openai-api-key` | `--openai-base-url` | — | — | -| Environment variables | — | Provider-specific mapping (e.g. `OPENAI_MODEL`) | Provider-specific mapping (e.g. `OPENAI_API_KEY`) | Provider-specific mapping (e.g. `OPENAI_BASE_URL`) | — | — | -| Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — | -| Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3.5-plus`) | — | — | — | `Config.getProxy()` if configured | +| Layer (highest → lowest) | authType | model | apiKey | baseUrl | apiKeyEnvKey | proxy | +| -------------------------- | ----------------------------------- | ----------------------------------------------- | ------------------------------------------------- | -------------------------------------------------- | ---------------------- | --------------------------------- | +| Programmatic overrides | `/auth` | `/auth` input | `/auth` input | `/auth` input | — | — | +| Model provider selection | — | `modelProvider.id` | `env[modelProvider.envKey]` | `modelProvider.baseUrl` | `modelProvider.envKey` | — | +| CLI arguments | `--auth-type` | `--model` | `--openai-api-key` | `--openai-base-url` | — | — | +| Environment variables | — | Provider-specific mapping (e.g. `OPENAI_MODEL`) | Provider-specific mapping (e.g. `OPENAI_API_KEY`) | Provider-specific mapping (e.g. `OPENAI_BASE_URL`) | — | — | +| Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — | +| Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3.5-plus`) | — | — | — | `Config.getProxy()` if configured | \*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration. diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index f15c7fb1d70..861a3024003 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -76,43 +76,43 @@ Commands specifically for controlling interface and output language. Commands for managing AI tools and models. -| Command | Description | Usage Examples | -| ----------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | -| `/mcp` | List configured MCP servers and tools | `/mcp`, `/mcp desc`, `/mcp nodesc`, `/mcp schema` | -| `/import-config` | Import MCP servers from Claude configs | `/import-config all`, `/import-config claude-code`, `/import-config claude-desktop --scope user\|project` | -| `/tools` | Display currently available tool list | `/tools`, `/tools desc` | -| `/skills` | Open the Skills panel to browse, search, toggle, and launch skills | `/skills`, `/` | -| `/plan` | Switch to plan mode or exit plan mode | `/plan`, `/plan `, `/plan exit` | -| `/approval-mode` | Change the tool-approval mode (current session only) | `/approval-mode`, `/approval-mode auto-edit` | -| → `plan` | Analysis only, no execution (secure review) | `/approval-mode plan` | -| → `default` | Require approval for edits (daily use) | `/approval-mode default` | -| → `auto-edit` | Auto-approve edits (trusted environment) | `/approval-mode auto-edit` | -| → `auto` | Classifier-evaluated approval (autonomous) | `/approval-mode auto` | -| → `yolo` | Auto-approve everything (quick prototyping) | `/approval-mode yolo` | -| `/model` | Switch model used in current session | `/model`, `/model ` (switch immediately) | -| `/model --fast` | Set a lighter model for prompt suggestions | `/model --fast qwen3-coder-flash` | -| `/model --voice` | Set the model used for voice transcription | `/model --voice ` | -| `/model --vision` | Set the vision-bridge model used to transcribe images for a text-only main model | `/model --vision ` | -| `/effort` | Set reasoning effort for thinking-capable models | `/effort` (opens picker), `/effort high` (low/medium/high/xhigh/max; mapped & clamped per provider) | -| `/extensions` | Manage extensions | `/extensions list`, `/extensions manage` | -| → `list` | List installed extensions | `/extensions list` | -| → `manage` | Manage installed extensions (interactive) | `/extensions manage` | -| → `explore` | Open extensions page in browser | `/extensions explore ` | -| → `install` | Install an extension from a git repo or path | `/extensions install ` | -| `/memory` | Open the Memory Manager dialog | `/memory` | -| `/remember` | Save a durable memory | `/remember Prefer terse responses` | -| `/forget` | Remove matching entries from auto-memory | `/forget ` | -| `/dream` | Manually run auto-memory consolidation | `/dream` | -| `/hooks` | Manage Qwen Code hooks | `/hooks`, `/hooks list` | -| `/reload-plugins` | Reload extension changes (commands, skills, agents, hooks, MCP/LSP servers) from disk | `/reload-plugins` | -| `/permissions` | Manage permission rules | `/permissions` | -| `/agents` | Manage subagents | `/agents manage`, `/agents create` | -| `/arena` | Manage Arena sessions | `/arena start`, `/arena stop`, `/arena status`, `/arena select` (alias `choose`) | -| `/goal` | Set a goal — keep working until condition met | `/goal `, `/goal clear` | -| `/tasks` | List background tasks | `/tasks` | -| `/workflows` | Inspect workflow runs | `/workflows`, `/workflows ` | -| `/lsp` | Show LSP server status | `/lsp` | -| `/trust` | Manage folder trust settings | `/trust` | +| Command | Description | Usage Examples | +| ----------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `/mcp` | List configured MCP servers and tools | `/mcp`, `/mcp desc`, `/mcp nodesc`, `/mcp schema` | +| `/import-config` | Import MCP servers from Claude configs | `/import-config all`, `/import-config claude-code`, `/import-config claude-desktop --scope user\|project` | +| `/tools` | Display currently available tool list | `/tools`, `/tools desc` | +| `/skills` | Open the Skills panel to browse, search, toggle, and launch skills | `/skills`, `/` | +| `/plan` | Switch to plan mode or exit plan mode | `/plan`, `/plan `, `/plan exit` | +| `/approval-mode` | Change the tool-approval mode (current session only) | `/approval-mode`, `/approval-mode auto-edit` | +| → `plan` | Analysis only, no execution (secure review) | `/approval-mode plan` | +| → `default` | Require approval for edits (daily use) | `/approval-mode default` | +| → `auto-edit` | Auto-approve edits (trusted environment) | `/approval-mode auto-edit` | +| → `auto` | Classifier-evaluated approval (autonomous) | `/approval-mode auto` | +| → `yolo` | Auto-approve everything (quick prototyping) | `/approval-mode yolo` | +| `/model` | Switch model used in current session | `/model`, `/model ` (switch immediately) | +| `/model --fast` | Set a lighter model for prompt suggestions | `/model --fast qwen3-coder-flash` | +| `/model --voice` | Set the model used for voice transcription | `/model --voice ` | +| `/model --vision` | Set the vision-bridge model used to transcribe images for a text-only main model | `/model --vision ` | +| `/effort` | Set reasoning effort for thinking-capable models | `/effort` (opens picker), `/effort high` (low/medium/high/xhigh/max; mapped & clamped per provider) | +| `/extensions` | Manage extensions | `/extensions list`, `/extensions manage` | +| → `list` | List installed extensions | `/extensions list` | +| → `manage` | Manage installed extensions (interactive) | `/extensions manage` | +| → `explore` | Open extensions page in browser | `/extensions explore ` | +| → `install` | Install an extension from a git repo or path | `/extensions install ` | +| `/memory` | Open the Memory Manager dialog | `/memory` | +| `/remember` | Save a durable memory | `/remember Prefer terse responses` | +| `/forget` | Remove matching entries from auto-memory | `/forget ` | +| `/dream` | Manually run auto-memory consolidation | `/dream` | +| `/hooks` | Manage Qwen Code hooks | `/hooks`, `/hooks list` | +| `/reload-plugins` | Reload extension changes (commands, skills, agents, hooks, MCP/LSP servers) from disk | `/reload-plugins` | +| `/permissions` | Manage permission rules | `/permissions` | +| `/agents` | Manage subagents | `/agents manage`, `/agents create` | +| `/arena` | Manage Arena sessions | `/arena start`, `/arena stop`, `/arena status`, `/arena select` (alias `choose`) | +| `/goal` | Set a goal — keep working until condition met | `/goal `, `/goal clear` | +| `/tasks` | List background tasks | `/tasks` | +| `/workflows` | Inspect workflow runs | `/workflows`, `/workflows ` | +| `/lsp` | Show LSP server status | `/lsp` | +| `/trust` | Manage folder trust settings | `/trust` | > [!warning] > @@ -327,7 +327,7 @@ Commands for obtaining information and performing system settings. | `/stats monthly` | Show monthly token usage statistics | `/stats monthly` (alias `month`), `/stats month [YYYY-MM]` | | `/stats export` | Export usage statistics to CSV or JSON | `/stats export [date\|month] [--format csv\|json] [--output path]` | | `/settings` | Open settings editor | `/settings` | -| `/config` | Get or set any setting by dot-path key (writes to user settings) | `/config` (list all), `/config `, `/config =` | +| `/config` | Get or set any setting by dot-path key (writes to user settings) | `/config` (list all), `/config `, `/config =` | | `/auth` | Change authentication method | `/auth`, `/connect`, `/login` | | `/doctor` | Run installation and environment diagnostics | `/doctor`, `/doctor memory` | | → `memory` | Show current process memory diagnostics | `/doctor memory [--json] [--sample] [--snapshot]` | diff --git a/docs/users/features/computer-use.md b/docs/users/features/computer-use.md index 3938e6d675d..187b1eb4d18 100644 --- a/docs/users/features/computer-use.md +++ b/docs/users/features/computer-use.md @@ -18,9 +18,9 @@ To disable it entirely — which also prevents the native driver from being down { "tools": { "computerUse": { - "enabled": false - } - } + "enabled": false, + }, + }, } ``` @@ -37,22 +37,22 @@ On macOS, desktop automation requires two system permissions: - **Accessibility** — to read window/UI state and synthesize input - **Screen Recording** — to capture screenshots -On first use the driver walks you through granting these via the standard macOS system dialogs. The agent can also check permission status on demand (the `check_permissions` tool). Because macOS attributes permission grants to the *responsible* process, grants may need to be given to the terminal or IDE that launched Qwen Code. +On first use the driver walks you through granting these via the standard macOS system dialogs. The agent can also check permission status on demand (the `check_permissions` tool). Because macOS attributes permission grants to the _responsible_ process, grants may need to be given to the terminal or IDE that launched Qwen Code. ## What the agent can do The full `cua-driver` tool surface is exposed. Highlights: -| Category | Tools (a selection) | -| ------------- | ----------------------------------------------------------------------------------- | -| Mouse | `click`, `double_click`, `right_click`, `drag`, `move_cursor`, `scroll` | -| Keyboard | `type_text`, `press_key`, `hotkey` | -| Windows / UI | `list_windows`, `get_window_state`, `get_accessibility_tree`, `set_value`, `zoom` | -| Apps | `launch_app`, `list_apps`, `bring_to_front`, `kill_app` | -| Browser pages | `page` (execute JavaScript, read text, query the DOM, click elements) | -| Screenshots | `get_window_state` (captures a PNG), `page` | -| Recording | `start_recording`, `stop_recording`, `replay_trajectory` (record/replay a session) | -| Sessions | `start_session`, `end_session`, agent-cursor overlay controls | +| Category | Tools (a selection) | +| ------------- | ---------------------------------------------------------------------------------- | +| Mouse | `click`, `double_click`, `right_click`, `drag`, `move_cursor`, `scroll` | +| Keyboard | `type_text`, `press_key`, `hotkey` | +| Windows / UI | `list_windows`, `get_window_state`, `get_accessibility_tree`, `set_value`, `zoom` | +| Apps | `launch_app`, `list_apps`, `bring_to_front`, `kill_app` | +| Browser pages | `page` (execute JavaScript, read text, query the DOM, click elements) | +| Screenshots | `get_window_state` (captures a PNG), `page` | +| Recording | `start_recording`, `stop_recording`, `replay_trajectory` (record/replay a session) | +| Sessions | `start_session`, `end_session`, agent-cursor overlay controls | Element-addressed actions are preferred over raw pixel coordinates: `get_window_state` returns a Markdown rendering of a window's accessibility tree with a stable `element_index` for each actionable element, which the input tools can target directly. @@ -62,11 +62,11 @@ Support is most complete on macOS; some tools are platform-specific (for example All Computer Use settings live under `tools.computerUse` in `settings.json`. See the [Settings reference](../configuration/settings.md) for the authoritative list. -| Setting | Type | Default | Description | -| ---------------------------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `tools.computerUse.enabled` | boolean | `true` | Register the `computer_use__*` tools. When `false`, the driver is never downloaded or spawned. | +| Setting | Type | Default | Description | +| ------------------------------------- | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tools.computerUse.enabled` | boolean | `true` | Register the `computer_use__*` tools. When `false`, the driver is never downloaded or spawned. | | `tools.computerUse.maxImageDimension` | number | `-1` | Longest-edge pixel cap for screenshots. `-1` keeps the driver's default (1568); `0` disables resizing (full resolution); a positive value caps the longest edge. Lower caps cut vision-token cost. Env override: `QWEN_COMPUTER_USE_MAX_IMAGE_DIMENSION`. | -| `tools.computerUse.idleTimeoutMs` | number | `300000` | Milliseconds to keep the driver process alive after the last `computer_use__*` call (default 5 minutes). `0` keeps it running until Qwen Code exits. | +| `tools.computerUse.idleTimeoutMs` | number | `300000` | Milliseconds to keep the driver process alive after the last `computer_use__*` call (default 5 minutes). `0` keeps it running until Qwen Code exits. | All three settings require a restart to take effect. diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index 240cad3fbc9..5773f30137b 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -309,7 +309,7 @@ Notes: | `--channel ` | — | Experimental daemon-managed channel worker. Repeat the flag to select multiple configured channels, or pass `all` to start every configured channel. `all` cannot be combined with named channels. Selected channel `cwd` values must resolve to the daemon workspace. The worker is owned by `qwen serve`; stop the daemon to stop serve-managed channels. | | `--max-connections ` | `256` | Listener-level TCP connection cap (`server.maxConnections`). Bounds raw socket count irrespective of session count — slow / phantom SSE clients get rejected at accept time once full. Raise alongside `--max-sessions` if your deployment expects many SSE subscribers per session. | | `--event-ring-size ` | `8000` | Per-session SSE replay ring depth (#3803 §02 target). Sets the backlog available to `GET /session/:id/events` with `Last-Event-ID: N`. Larger = more reconnect headroom at the cost of a few hundred KB extra RAM per session. SDK clients can additionally request a larger per-subscriber backlog cap on a specific subscription via `?maxQueued=N` (range `[16, 2048]`, default 256). Daemons also emit a non-terminal `slow_client_warning` SSE frame at 75% queue fill so clients can drain / reconnect before getting evicted. Pre-flight `caps.features.slow_client_warning`. | -| `--compacted-replay-max-bytes ` | `4194304` | Per-live-session byte cap for the retained replay events in the bounded snapshot returned by `POST /session/:id/load`. The cap applies to `compactedReplay`; the current in-flight `liveJournal` remains uncapped. Values must be positive safe integers; invalid values fail at boot, and the hard ceiling is 256 MiB. When older retained replay is dropped, the snapshot begins with `history_truncated`. This does not limit the on-disk transcript. | +| `--compacted-replay-max-bytes ` | `4194304` | Per-live-session byte cap for the retained replay events in the bounded snapshot returned by `POST /session/:id/load`. The cap applies to `compactedReplay`; the current in-flight `liveJournal` remains uncapped. Values must be positive safe integers; invalid values fail at boot, and the hard ceiling is 256 MiB. When older retained replay is dropped, the snapshot begins with `history_truncated`. This does not limit the on-disk transcript. | | `--mcp-client-budget ` | — | Positive integer cap on live MCP clients **per ACP session** (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 14 v1; PR 23 graduates this to per-workspace via the shared MCP pool). Combine with `--mcp-budget-mode`. When unset, no accounting-driven enforcement (but `GET /workspace/mcp` still reports `clientCount`). Distinct from claude-code's `MCP_SERVER_CONNECTION_BATCH_SIZE` which gates startup concurrency, not the total client count. Pre-flight `caps.features.mcp_guardrails`. | | `--mcp-budget-mode ` | `warn` / `off` | How `--mcp-client-budget` is enforced. `warn` (default when budget set): no refusal, snapshot's `budgets[0].status` flips to `warning` at ≥75% of budget. `enforce`: connects past the cap are refused, per-server cell shows `disabledReason: 'budget'`, deterministic by `mcpServers` declaration order. `off` (default when budget unset): pure observability. Boot rejects `enforce` without a budget. | | `--http-bridge` | `true` | Stage 1 mode: one `qwen --acp` child per daemon (bound to one workspace at boot, per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §02); N sessions multiplex onto that child via ACP `newSession()`. Stage 2 native in-process becomes available later. | diff --git a/integration-tests/cli/qwen-serve-client-mcp.test.ts b/integration-tests/cli/qwen-serve-client-mcp.test.ts index 0ac716eaf14..4a55f9952c7 100644 --- a/integration-tests/cli/qwen-serve-client-mcp.test.ts +++ b/integration-tests/cli/qwen-serve-client-mcp.test.ts @@ -108,7 +108,9 @@ beforeAll(async () => { if (wantsReadPage && hasToolResult) { return { content: FINAL_ASSISTANT_TEXT }; } - return { content: 'unused — this suite only prompts in the tools/call test' }; + return { + content: 'unused — this suite only prompts in the tools/call test', + }; }); homeDir = mkdtempSync(path.join(tmpdir(), 'qwen-serve-client-mcp-home-')); daemon = spawn( @@ -212,7 +214,9 @@ function answerHandshakeFrame( }; }, onReadPageCall?: (args: unknown) => void, -): { type: 'mcp_message'; id: string; server: string; payload: unknown } | undefined { +): + | { type: 'mcp_message'; id: string; server: string; payload: unknown } + | undefined { const { payload } = frame; if (payload.id === undefined || payload.id === null) return undefined; // notification let result: unknown; @@ -251,7 +255,10 @@ function answerHandshakeFrame( payload: { jsonrpc: '2.0', id: payload.id, - error: { code: -32602, message: `unknown tool: ${String(toolName)}` }, + error: { + code: -32602, + message: `unknown tool: ${String(toolName)}`, + }, }, }; } @@ -275,7 +282,10 @@ function answerHandshakeFrame( payload: { jsonrpc: '2.0', id: payload.id, - error: { code: -32601, message: `method not found: ${payload.method}` }, + error: { + code: -32601, + message: `method not found: ${payload.method}`, + }, }, }; } @@ -287,435 +297,473 @@ function answerHandshakeFrame( }; } -describeMaybe('qwen serve — reverse tool channel (client-hosted MCP over WS)', () => { - it('discovers a client-hosted tool end-to-end via the ACP child', async () => { - const ws = new WebSocket(`ws://127.0.0.1:${port}/acp`, { - headers: { Authorization: `Bearer ${TOKEN}` }, - }); - await new Promise((resolve, reject) => { - ws.once('open', () => resolve()); - ws.once('error', reject); - }); - - // Demux: ACP JSON-RPC replies (by id) and client-MCP frames (by type). - const acpReplies = new Map>(); - let registeredAck: Record | undefined; - ws.on('message', (data) => { - const msg = JSON.parse(data.toString()) as Record; - if (msg['type'] === 'mcp_message') { - const reply = answerHandshakeFrame( - msg as unknown as { - id: string; - server: string; - payload: { id?: number | string; method?: string }; - }, - ); - if (reply) ws.send(JSON.stringify(reply)); - return; - } - if (msg['type'] === 'mcp_registered' || msg['type'] === 'mcp_error') { - registeredAck = msg; - return; - } - if (typeof msg['id'] === 'number') { - acpReplies.set(msg['id'] as number, msg); - } - }); +describeMaybe( + 'qwen serve — reverse tool channel (client-hosted MCP over WS)', + () => { + it('discovers a client-hosted tool end-to-end via the ACP child', async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/acp`, { + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + await new Promise((resolve, reject) => { + ws.once('open', () => resolve()); + ws.once('error', reject); + }); - const waitForAcp = (id: number, timeoutMs = 20_000) => - new Promise>((resolve, reject) => { - const started = Date.now(); - const tick = () => { - const r = acpReplies.get(id); - if (r) return resolve(r); - if (Date.now() - started > timeoutMs) - return reject(new Error(`timeout waiting for ACP reply id=${id}`)); - setTimeout(tick, 25); - }; - tick(); + // Demux: ACP JSON-RPC replies (by id) and client-MCP frames (by type). + const acpReplies = new Map>(); + let registeredAck: Record | undefined; + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) as Record; + if (msg['type'] === 'mcp_message') { + const reply = answerHandshakeFrame( + msg as unknown as { + id: string; + server: string; + payload: { id?: number | string; method?: string }; + }, + ); + if (reply) ws.send(JSON.stringify(reply)); + return; + } + if (msg['type'] === 'mcp_registered' || msg['type'] === 'mcp_error') { + registeredAck = msg; + return; + } + if (typeof msg['id'] === 'number') { + acpReplies.set(msg['id'] as number, msg); + } }); - // 1. initialize - ws.send( - JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }), - ); - await waitForAcp(1); - - // 2. session/new — spawns the real ACP child + binds the session manager's - // sendSdkMcpMessage to the client_mcp/message ext-method. - ws.send( - JSON.stringify({ - jsonrpc: '2.0', - id: 2, - method: 'session/new', - params: { cwd: REPO_ROOT }, - }), - ); - const sessionReply = await waitForAcp(2, 30_000); - const sessionId = (sessionReply['result'] as { sessionId?: string }) - ?.sessionId; - expect(typeof sessionId).toBe('string'); - - // 3. mcp_register — provider adds an SDK-type runtime server in the child; - // the child's discovery handshake round-trips back over THIS WS. - ws.send(JSON.stringify({ type: 'mcp_register', server: 'chrome-tools' })); - - // 4. wait for the registration ack (proves the child discovered the tool). - await new Promise((resolve, reject) => { - const started = Date.now(); - const tick = () => { - if (registeredAck) return resolve(); - if (Date.now() - started > 25_000) - return reject(new Error('timeout waiting for mcp_registered')); - setTimeout(tick, 25); - }; - tick(); - }); + const waitForAcp = (id: number, timeoutMs = 20_000) => + new Promise>((resolve, reject) => { + const started = Date.now(); + const tick = () => { + const r = acpReplies.get(id); + if (r) return resolve(r); + if (Date.now() - started > timeoutMs) + return reject( + new Error(`timeout waiting for ACP reply id=${id}`), + ); + setTimeout(tick, 25); + }; + tick(); + }); + + // 1. initialize + ws.send( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: {}, + }), + ); + await waitForAcp(1); - // A surprising `mcp_error` here means the round-trip broke somewhere in the - // child → parent → WS chain; surface its code/message for triage. - expect( - registeredAck, - `expected mcp_registered, got ${JSON.stringify(registeredAck)}`, - ).toMatchObject({ type: 'mcp_registered', server: 'chrome-tools' }); - expect(registeredAck?.['toolCount']).toBe(1); - - // 5. Secondary confirm: the child's tool registry surfaces the tool via the - // workspace MCP tools route (REST, separate from the WS). - const toolsRes = await fetch(`${base}/workspace/mcp/chrome-tools/tools`, { - headers: { Authorization: `Bearer ${TOKEN}` }, - }); - expect(toolsRes.status).toBe(200); - const toolsBody = (await toolsRes.json()) as { - tools?: Array<{ name?: string; serverToolName?: string }>; - }; - // Tool names may be server-prefixed in the registry; match the raw tool id - // against both the registered `name` and the un-prefixed `serverToolName`. - const hasReadPage = (toolsBody.tools ?? []).some( - (t) => - t.serverToolName === 'chrome_read_page' || - (t.name ?? '').includes('chrome_read_page'), - ); - expect(hasReadPage).toBe(true); - - ws.close(); - }, 60_000); - - // FULL reverse-channel loop, end-to-end: this test drives the genuine - // model→agent→tools/call→reverse-WS→ws-client→result path and asserts the - // tool result is consumed by the agent's turn. - // - // The session-scoping fix (#5626) makes the runtime-added client-hosted MCP - // server reach the PER-SESSION tool registry, not just the bootstrap one: - // - // • `mcp_register` → `workspaceMcpRuntimeAdd` adds the server to the - // BOOTSTRAP/workspace Config (so discovery + `GET /workspace/mcp/.../tools` - // see it) AND fans the add out to every active session's manager - // (packages/cli/src/acp-integration/acpAgent.ts), binding THAT session's - // `sendSdkMcpMessage` (the `__clientMcpOverWs` reverse path). - // • A session created LATER also inherits the bootstrap Config's runtime MCP - // servers in `newSessionConfig` before `config.initialize()`. - // - // So a model-driven `tools/call` for `chrome_read_page` now resolves in the - // session registry, crosses the reverse WS channel to this stand-in - // extension, returns a `CallToolResult`, and the agent's turn consumes it. - // - // This test does session/new THEN mcp_register (the "register after a session - // already exists" timing), exercising the fan-out path specifically. - // Under container sandboxing, the ACP child cannot reach the host-loopback - // fake model server used below; keep the discovery-only test running there. - itPromptedModelMaybe('drives a model→agent tools/call of chrome_read_page over the reverse WS channel and consumes the result', async () => { - const ws = new WebSocket(`ws://127.0.0.1:${port}/acp`, { - headers: { Authorization: `Bearer ${TOKEN}` }, - }); - await new Promise((resolve, reject) => { - ws.once('open', () => resolve()); - ws.once('error', reject); - }); + // 2. session/new — spawns the real ACP child + binds the session manager's + // sendSdkMcpMessage to the client_mcp/message ext-method. + ws.send( + JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'session/new', + params: { cwd: REPO_ROOT }, + }), + ); + const sessionReply = await waitForAcp(2, 30_000); + const sessionId = (sessionReply['result'] as { sessionId?: string }) + ?.sessionId; + expect(typeof sessionId).toBe('string'); - // Records every reverse-channel `tools/call` frame the stand-in extension - // saw, plus the forwarded arguments — this is the model→agent→child→parent→WS - // path the discovery test never exercises. - const readPageCalls: unknown[] = []; - - const acpReplies = new Map>(); - let registeredAck: Record | undefined; - ws.on('message', (data) => { - const msg = JSON.parse(data.toString()) as Record; - if (msg['type'] === 'mcp_message') { - // Same canned client-hosted MCP server as the discovery test, now also - // answering `tools/call`. Record `chrome_read_page` invocations so the - // assertions below can prove the reverse round-trip fired. - const reply = answerHandshakeFrame( - msg as unknown as { - id: string; - server: string; - payload: { - id?: number | string; - method?: string; - params?: { name?: string; arguments?: unknown }; - }; - }, - (args) => readPageCalls.push(args), - ); - if (reply) ws.send(JSON.stringify(reply)); - return; - } - if (msg['type'] === 'mcp_registered' || msg['type'] === 'mcp_error') { - registeredAck = msg; - return; - } - if (typeof msg['id'] === 'number') { - acpReplies.set(msg['id'] as number, msg); - } - }); + // 3. mcp_register — provider adds an SDK-type runtime server in the child; + // the child's discovery handshake round-trips back over THIS WS. + ws.send(JSON.stringify({ type: 'mcp_register', server: 'chrome-tools' })); - const waitForAcp = (id: number, timeoutMs = 20_000) => - new Promise>((resolve, reject) => { + // 4. wait for the registration ack (proves the child discovered the tool). + await new Promise((resolve, reject) => { const started = Date.now(); const tick = () => { - const r = acpReplies.get(id); - if (r) return resolve(r); - if (Date.now() - started > timeoutMs) - return reject(new Error(`timeout waiting for ACP reply id=${id}`)); + if (registeredAck) return resolve(); + if (Date.now() - started > 25_000) + return reject(new Error('timeout waiting for mcp_registered')); setTimeout(tick, 25); }; tick(); }); - // 1. initialize + 2. session/new (real ACP child) — identical to discovery. - ws.send( - JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }), - ); - await waitForAcp(1); - ws.send( - JSON.stringify({ - jsonrpc: '2.0', - id: 2, - method: 'session/new', - params: { cwd: REPO_ROOT }, - }), - ); - const sessionReply = await waitForAcp(2, 30_000); - const sessionId = (sessionReply['result'] as { sessionId?: string }) - ?.sessionId as string; - expect(typeof sessionId).toBe('string'); - - // 3. mcp_register chrome-tools + wait for the ack (tool discovered). - ws.send(JSON.stringify({ type: 'mcp_register', server: 'chrome-tools' })); - await new Promise((resolve, reject) => { - const started = Date.now(); - const tick = () => { - if (registeredAck) return resolve(); - if (Date.now() - started > 25_000) - return reject(new Error('timeout waiting for mcp_registered')); - setTimeout(tick, 25); + // A surprising `mcp_error` here means the round-trip broke somewhere in the + // child → parent → WS chain; surface its code/message for triage. + expect( + registeredAck, + `expected mcp_registered, got ${JSON.stringify(registeredAck)}`, + ).toMatchObject({ type: 'mcp_registered', server: 'chrome-tools' }); + expect(registeredAck?.['toolCount']).toBe(1); + + // 5. Secondary confirm: the child's tool registry surfaces the tool via the + // workspace MCP tools route (REST, separate from the WS). + const toolsRes = await fetch(`${base}/workspace/mcp/chrome-tools/tools`, { + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + expect(toolsRes.status).toBe(200); + const toolsBody = (await toolsRes.json()) as { + tools?: Array<{ name?: string; serverToolName?: string }>; }; - tick(); - }); - expect( - registeredAck, - `expected mcp_registered, got ${JSON.stringify(registeredAck)}`, - ).toMatchObject({ type: 'mcp_registered', server: 'chrome-tools' }); - expect(registeredAck?.['toolCount']).toBe(1); - - // 4. Pin the session to `yolo` so the model-emitted tool call auto-approves - // (no human in the loop on the WS) — otherwise a `permission_request` would - // stall the turn forever. Matches the daemon's intended "extension drives - // tools unattended" posture. - const modeRes = await fetch(`${base}/session/${sessionId}/approval-mode`, { - method: 'POST', - headers: { - Authorization: `Bearer ${TOKEN}`, - 'content-type': 'application/json', - }, - body: JSON.stringify({ mode: 'yolo' }), - }); - expect(modeRes.status).toBe(200); - - // 5. Drive a real prompt over REST. The fake model returns a - // `chrome_read_page` tool_call on this turn (see beforeAll), so the agent - // must invoke the client-hosted tool through the reverse WS channel. - const promptRes = await fetch(`${base}/session/${sessionId}/prompt`, { - method: 'POST', - headers: { - Authorization: `Bearer ${TOKEN}`, - 'content-type': 'application/json', - }, - body: JSON.stringify({ - prompt: [ + // Tool names may be server-prefixed in the registry; match the raw tool id + // against both the registered `name` and the un-prefixed `serverToolName`. + const hasReadPage = (toolsBody.tools ?? []).some( + (t) => + t.serverToolName === 'chrome_read_page' || + (t.name ?? '').includes('chrome_read_page'), + ); + expect(hasReadPage).toBe(true); + + ws.close(); + }, 60_000); + + // FULL reverse-channel loop, end-to-end: this test drives the genuine + // model→agent→tools/call→reverse-WS→ws-client→result path and asserts the + // tool result is consumed by the agent's turn. + // + // The session-scoping fix (#5626) makes the runtime-added client-hosted MCP + // server reach the PER-SESSION tool registry, not just the bootstrap one: + // + // • `mcp_register` → `workspaceMcpRuntimeAdd` adds the server to the + // BOOTSTRAP/workspace Config (so discovery + `GET /workspace/mcp/.../tools` + // see it) AND fans the add out to every active session's manager + // (packages/cli/src/acp-integration/acpAgent.ts), binding THAT session's + // `sendSdkMcpMessage` (the `__clientMcpOverWs` reverse path). + // • A session created LATER also inherits the bootstrap Config's runtime MCP + // servers in `newSessionConfig` before `config.initialize()`. + // + // So a model-driven `tools/call` for `chrome_read_page` now resolves in the + // session registry, crosses the reverse WS channel to this stand-in + // extension, returns a `CallToolResult`, and the agent's turn consumes it. + // + // This test does session/new THEN mcp_register (the "register after a session + // already exists" timing), exercising the fan-out path specifically. + // Under container sandboxing, the ACP child cannot reach the host-loopback + // fake model server used below; keep the discovery-only test running there. + itPromptedModelMaybe( + 'drives a model→agent tools/call of chrome_read_page over the reverse WS channel and consumes the result', + async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/acp`, { + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + await new Promise((resolve, reject) => { + ws.once('open', () => resolve()); + ws.once('error', reject); + }); + + // Records every reverse-channel `tools/call` frame the stand-in extension + // saw, plus the forwarded arguments — this is the model→agent→child→parent→WS + // path the discovery test never exercises. + const readPageCalls: unknown[] = []; + + const acpReplies = new Map>(); + let registeredAck: Record | undefined; + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) as Record; + if (msg['type'] === 'mcp_message') { + // Same canned client-hosted MCP server as the discovery test, now also + // answering `tools/call`. Record `chrome_read_page` invocations so the + // assertions below can prove the reverse round-trip fired. + const reply = answerHandshakeFrame( + msg as unknown as { + id: string; + server: string; + payload: { + id?: number | string; + method?: string; + params?: { name?: string; arguments?: unknown }; + }; + }, + (args) => readPageCalls.push(args), + ); + if (reply) ws.send(JSON.stringify(reply)); + return; + } + if (msg['type'] === 'mcp_registered' || msg['type'] === 'mcp_error') { + registeredAck = msg; + return; + } + if (typeof msg['id'] === 'number') { + acpReplies.set(msg['id'] as number, msg); + } + }); + + const waitForAcp = (id: number, timeoutMs = 20_000) => + new Promise>((resolve, reject) => { + const started = Date.now(); + const tick = () => { + const r = acpReplies.get(id); + if (r) return resolve(r); + if (Date.now() - started > timeoutMs) + return reject( + new Error(`timeout waiting for ACP reply id=${id}`), + ); + setTimeout(tick, 25); + }; + tick(); + }); + + // 1. initialize + 2. session/new (real ACP child) — identical to discovery. + ws.send( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: {}, + }), + ); + await waitForAcp(1); + ws.send( + JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'session/new', + params: { cwd: REPO_ROOT }, + }), + ); + const sessionReply = await waitForAcp(2, 30_000); + const sessionId = (sessionReply['result'] as { sessionId?: string }) + ?.sessionId as string; + expect(typeof sessionId).toBe('string'); + + // 3. mcp_register chrome-tools + wait for the ack (tool discovered). + ws.send( + JSON.stringify({ type: 'mcp_register', server: 'chrome-tools' }), + ); + await new Promise((resolve, reject) => { + const started = Date.now(); + const tick = () => { + if (registeredAck) return resolve(); + if (Date.now() - started > 25_000) + return reject(new Error('timeout waiting for mcp_registered')); + setTimeout(tick, 25); + }; + tick(); + }); + expect( + registeredAck, + `expected mcp_registered, got ${JSON.stringify(registeredAck)}`, + ).toMatchObject({ type: 'mcp_registered', server: 'chrome-tools' }); + expect(registeredAck?.['toolCount']).toBe(1); + + // 4. Pin the session to `yolo` so the model-emitted tool call auto-approves + // (no human in the loop on the WS) — otherwise a `permission_request` would + // stall the turn forever. Matches the daemon's intended "extension drives + // tools unattended" posture. + const modeRes = await fetch( + `${base}/session/${sessionId}/approval-mode`, { - type: 'text', - text: `${READ_PAGE_PROMPT_SENTINEL}: read the current browser page and summarize it.`, + method: 'POST', + headers: { + Authorization: `Bearer ${TOKEN}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ mode: 'yolo' }), }, - ], - }), - }); - expect(promptRes.status).toBe(202); - const { promptId, lastEventId } = (await promptRes.json()) as { - promptId: string; - lastEventId: number; - }; - expect(typeof promptId).toBe('string'); - - // 6. Subscribe to the session SSE stream from the cursor BEFORE this turn so - // no tool_call / tool_call_update / turn_complete frame is missed. Collect - // until `turn_complete` for THIS promptId (or timeout). - const sseAbort = new AbortController(); - const events: Array<{ type: string; data: unknown }> = []; - const sseDone = (async () => { - const res = await fetch(`${base}/session/${sessionId}/events`, { - headers: { - Authorization: `Bearer ${TOKEN}`, - Accept: 'text/event-stream', - 'Last-Event-ID': String(lastEventId), - }, - signal: sseAbort.signal, - }); - if (!res.ok || !res.body) throw new Error(`SSE open failed: ${res.status}`); - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buf = ''; - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - buf += decoder.decode(value, { stream: true }); - // Parse complete SSE frames (separated by a blank line). - let sep: number; - while ((sep = buf.indexOf('\n\n')) !== -1) { - const rawFrame = buf.slice(0, sep); - buf = buf.slice(sep + 2); - let evType = 'message'; - const dataLines: string[] = []; - for (const line of rawFrame.split('\n')) { - if (line.startsWith('event:')) evType = line.slice(6).trim(); - else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim()); - } - if (dataLines.length === 0) continue; // heartbeat / comment - let parsed: unknown; - try { - parsed = JSON.parse(dataLines.join('\n')); - } catch { - continue; + ); + expect(modeRes.status).toBe(200); + + // 5. Drive a real prompt over REST. The fake model returns a + // `chrome_read_page` tool_call on this turn (see beforeAll), so the agent + // must invoke the client-hosted tool through the reverse WS channel. + const promptRes = await fetch(`${base}/session/${sessionId}/prompt`, { + method: 'POST', + headers: { + Authorization: `Bearer ${TOKEN}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + prompt: [ + { + type: 'text', + text: `${READ_PAGE_PROMPT_SENTINEL}: read the current browser page and summarize it.`, + }, + ], + }), + }); + expect(promptRes.status).toBe(202); + const { promptId, lastEventId } = (await promptRes.json()) as { + promptId: string; + lastEventId: number; + }; + expect(typeof promptId).toBe('string'); + + // 6. Subscribe to the session SSE stream from the cursor BEFORE this turn so + // no tool_call / tool_call_update / turn_complete frame is missed. Collect + // until `turn_complete` for THIS promptId (or timeout). + const sseAbort = new AbortController(); + const events: Array<{ type: string; data: unknown }> = []; + const sseDone = (async () => { + const res = await fetch(`${base}/session/${sessionId}/events`, { + headers: { + Authorization: `Bearer ${TOKEN}`, + Accept: 'text/event-stream', + 'Last-Event-ID': String(lastEventId), + }, + signal: sseAbort.signal, + }); + if (!res.ok || !res.body) + throw new Error(`SSE open failed: ${res.status}`); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + // Parse complete SSE frames (separated by a blank line). + let sep: number; + while ((sep = buf.indexOf('\n\n')) !== -1) { + const rawFrame = buf.slice(0, sep); + buf = buf.slice(sep + 2); + let evType = 'message'; + const dataLines: string[] = []; + for (const line of rawFrame.split('\n')) { + if (line.startsWith('event:')) evType = line.slice(6).trim(); + else if (line.startsWith('data:')) + dataLines.push(line.slice(5).trim()); + } + if (dataLines.length === 0) continue; // heartbeat / comment + let parsed: unknown; + try { + parsed = JSON.parse(dataLines.join('\n')); + } catch { + continue; + } + const env = parsed as { type?: string; data?: unknown }; + events.push({ type: env.type ?? evType, data: env.data }); + const isTurnComplete = + (env.type ?? evType) === 'turn_complete' && + (env.data as { promptId?: string })?.promptId === promptId; + if (isTurnComplete) return; + } } - const env = parsed as { type?: string; data?: unknown }; - events.push({ type: env.type ?? evType, data: env.data }); - const isTurnComplete = - (env.type ?? evType) === 'turn_complete' && - (env.data as { promptId?: string })?.promptId === promptId; - if (isTurnComplete) return; + } finally { + reader.cancel().catch(() => {}); } + })(); + + // 7. Wait for the turn to complete (consuming the tool result) or time out. + let timedOut = false; + await Promise.race([ + sseDone, + new Promise((resolve) => + setTimeout(() => { + timedOut = true; + resolve(); + }, 40_000), + ), + ]); + sseAbort.abort(); + await sseDone.catch(() => {}); + if (timedOut) { + // A timeout (vs. a clean turn_complete) usually means the model call never + // reached the fake server — most often a localhost-bypassing HTTP proxy in + // the dev env. Surface the request count + event trace for triage. + throw new Error( + `timeout waiting for turn_complete; fakeReqs=${fakeServer.requests.length} ` + + `readPageCalls=${readPageCalls.length} ` + + `events=${JSON.stringify( + events.map((e) => ({ + t: e.type, + u: ( + e.data as { + update?: { sessionUpdate?: string; status?: string }; + } + )?.update?.sessionUpdate, + s: (e.data as { update?: { status?: string } })?.update + ?.status, + })), + )}`, + ); } - } finally { - reader.cancel().catch(() => {}); - } - })(); - - // 7. Wait for the turn to complete (consuming the tool result) or time out. - let timedOut = false; - await Promise.race([ - sseDone, - new Promise((resolve) => - setTimeout(() => { - timedOut = true; - resolve(); - }, 40_000), - ), - ]); - sseAbort.abort(); - await sseDone.catch(() => {}); - if (timedOut) { - // A timeout (vs. a clean turn_complete) usually means the model call never - // reached the fake server — most often a localhost-bypassing HTTP proxy in - // the dev env. Surface the request count + event trace for triage. - throw new Error( - `timeout waiting for turn_complete; fakeReqs=${fakeServer.requests.length} ` + - `readPageCalls=${readPageCalls.length} ` + - `events=${JSON.stringify( - events.map((e) => ({ - t: e.type, - u: (e.data as { update?: { sessionUpdate?: string; status?: string } }) - ?.update?.sessionUpdate, - s: (e.data as { update?: { status?: string } })?.update?.status, - })), - )}`, - ); - } - // Collect the tool-call lifecycle the agent surfaced for THIS turn. - const toolCallUpdates = events.filter( - (e) => - e.type === 'session_update' && - ((e.data as { update?: { sessionUpdate?: string } })?.update - ?.sessionUpdate === 'tool_call' || - (e.data as { update?: { sessionUpdate?: string } })?.update - ?.sessionUpdate === 'tool_call_update'), - ); - const readPageUpdate = toolCallUpdates.find((e) => { - const u = (e.data as { update?: Record })?.update ?? {}; - const meta = u['_meta'] as { toolName?: string } | undefined; - const contentText = JSON.stringify(u['content'] ?? ''); - return ( - meta?.toolName === 'chrome_read_page' || - contentText.includes('chrome_read_page') || - String(u['title'] ?? '').includes('chrome_read_page') - ); - }); + // Collect the tool-call lifecycle the agent surfaced for THIS turn. + const toolCallUpdates = events.filter( + (e) => + e.type === 'session_update' && + ((e.data as { update?: { sessionUpdate?: string } })?.update + ?.sessionUpdate === 'tool_call' || + (e.data as { update?: { sessionUpdate?: string } })?.update + ?.sessionUpdate === 'tool_call_update'), + ); + const readPageUpdate = toolCallUpdates.find((e) => { + const u = + (e.data as { update?: Record })?.update ?? {}; + const meta = u['_meta'] as { toolName?: string } | undefined; + const contentText = JSON.stringify(u['content'] ?? ''); + return ( + meta?.toolName === 'chrome_read_page' || + contentText.includes('chrome_read_page') || + String(u['title'] ?? '').includes('chrome_read_page') + ); + }); + + // ── The model→agent dispatch fired ────────────────────────────────────── + // The model emitted a `chrome_read_page` tool call (the fake server saw the + // prompt) and the agent surfaced a tool_call(_update) for it — i.e. the + // prompt is wired through to the agent's tool dispatcher for the + // client-hosted tool name. + expect(fakeServer.requests.length).toBeGreaterThanOrEqual(1); + expect( + readPageUpdate, + `expected a tool_call(_update) naming chrome_read_page; ` + + `events=${JSON.stringify(events.map((e) => e.type))}`, + ).toBeDefined(); + + // ── SUCCESS PATH (session-scoped runtime MCP — #5626) ─────────────────── + // (a) The stand-in extension RECEIVED the reverse `tools/call`: the agent + // resolved `chrome_read_page` in the SESSION registry, bound the session's + // `sendSdkMcpMessage`, and the frame crossed the WS to this client. + expect( + readPageCalls.length, + `expected the reverse tools/call to reach the ws client; ` + + `updates=${JSON.stringify( + toolCallUpdates.map( + (e) => + (e.data as { update?: { status?: string } })?.update?.status, + ), + )}`, + ).toBeGreaterThanOrEqual(1); + // The model emitted args `{}`, forwarded verbatim over the reverse channel. + expect(typeof readPageCalls[0]).toBe('object'); + + // (b) The agent CONSUMED the result — the tool call reached `completed`. + const completed = toolCallUpdates.some( + (e) => + (e.data as { update?: { status?: string } })?.update?.status === + 'completed', + ); + expect( + completed, + `expected a completed tool_call_update for chrome_read_page; ` + + `statuses=${JSON.stringify( + toolCallUpdates.map( + (e) => + (e.data as { update?: { status?: string } })?.update?.status, + ), + )}`, + ).toBe(true); + + // (c) The turn ended cleanly (the agent fed the tool result back to the + // model, which returned its final assistant message). + const turnComplete = events.find( + (e) => + e.type === 'turn_complete' && + (e.data as { promptId?: string })?.promptId === promptId, + ); + expect( + turnComplete, + 'expected a turn_complete for this prompt', + ).toBeDefined(); - // ── The model→agent dispatch fired ────────────────────────────────────── - // The model emitted a `chrome_read_page` tool call (the fake server saw the - // prompt) and the agent surfaced a tool_call(_update) for it — i.e. the - // prompt is wired through to the agent's tool dispatcher for the - // client-hosted tool name. - expect(fakeServer.requests.length).toBeGreaterThanOrEqual(1); - expect( - readPageUpdate, - `expected a tool_call(_update) naming chrome_read_page; ` + - `events=${JSON.stringify(events.map((e) => e.type))}`, - ).toBeDefined(); - - // ── SUCCESS PATH (session-scoped runtime MCP — #5626) ─────────────────── - // (a) The stand-in extension RECEIVED the reverse `tools/call`: the agent - // resolved `chrome_read_page` in the SESSION registry, bound the session's - // `sendSdkMcpMessage`, and the frame crossed the WS to this client. - expect( - readPageCalls.length, - `expected the reverse tools/call to reach the ws client; ` + - `updates=${JSON.stringify( - toolCallUpdates.map( - (e) => (e.data as { update?: { status?: string } })?.update?.status, - ), - )}`, - ).toBeGreaterThanOrEqual(1); - // The model emitted args `{}`, forwarded verbatim over the reverse channel. - expect(typeof readPageCalls[0]).toBe('object'); - - // (b) The agent CONSUMED the result — the tool call reached `completed`. - const completed = toolCallUpdates.some( - (e) => - (e.data as { update?: { status?: string } })?.update?.status === - 'completed', - ); - expect( - completed, - `expected a completed tool_call_update for chrome_read_page; ` + - `statuses=${JSON.stringify( - toolCallUpdates.map( - (e) => (e.data as { update?: { status?: string } })?.update?.status, - ), - )}`, - ).toBe(true); - - // (c) The turn ended cleanly (the agent fed the tool result back to the - // model, which returned its final assistant message). - const turnComplete = events.find( - (e) => - e.type === 'turn_complete' && - (e.data as { promptId?: string })?.promptId === promptId, + ws.close(); + }, + 90_000, ); - expect(turnComplete, 'expected a turn_complete for this prompt').toBeDefined(); - - ws.close(); - }, 90_000); -}); + }, +); diff --git a/packages/cli/src/commands/extensions/consent.ts b/packages/cli/src/commands/extensions/consent.ts index 95c2c291ee1..8660fd1f961 100644 --- a/packages/cli/src/commands/extensions/consent.ts +++ b/packages/cli/src/commands/extensions/consent.ts @@ -163,9 +163,7 @@ export function extensionConsentString( } const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {}); const displayLabel = extensionConfig.displayName ?? extensionConfig.name; - output.push( - t('Installing extension "{{name}}".', { name: displayLabel }), - ); + output.push(t('Installing extension "{{name}}".', { name: displayLabel })); if ( typeof extensionConfig.description === 'string' && extensionConfig.description diff --git a/packages/cli/src/commands/extensions/list.ts b/packages/cli/src/commands/extensions/list.ts index 8a244e4435c..97a9ac9cb2f 100644 --- a/packages/cli/src/commands/extensions/list.ts +++ b/packages/cli/src/commands/extensions/list.ts @@ -8,11 +8,7 @@ import type { CommandModule } from 'yargs'; import { getErrorMessage } from '../../utils/errors.js'; import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import { extensionToOutputString, getExtensionManager } from './utils.js'; -import { - t, - initializeI18n, - resolveLanguageSetting, -} from '../../i18n/index.js'; +import { t, initializeI18n, resolveLanguageSetting } from '../../i18n/index.js'; import { loadSettings } from '../../config/settings.js'; export async function handleList() { diff --git a/packages/cli/src/i18n/index.ts b/packages/cli/src/i18n/index.ts index 3332e484aa0..7bfaca79f85 100644 --- a/packages/cli/src/i18n/index.ts +++ b/packages/cli/src/i18n/index.ts @@ -311,7 +311,7 @@ export async function initializeI18n( export function resolveLanguageSetting( settingsLanguage?: string, ): SupportedLanguage | 'auto' { - return ( - process.env['QWEN_CODE_LANG'] || settingsLanguage || 'auto' - ) as SupportedLanguage | 'auto'; + return (process.env['QWEN_CODE_LANG'] || settingsLanguage || 'auto') as + | SupportedLanguage + | 'auto'; } diff --git a/packages/cli/src/ui/components/extensions/steps/ScopeSelectStep.tsx b/packages/cli/src/ui/components/extensions/steps/ScopeSelectStep.tsx index bb4b3450edc..ff680af9511 100644 --- a/packages/cli/src/ui/components/extensions/steps/ScopeSelectStep.tsx +++ b/packages/cli/src/ui/components/extensions/steps/ScopeSelectStep.tsx @@ -56,10 +56,16 @@ export function ScopeSelectStep({ const title = mode === 'disable' ? t('Disable "{{name}}" - Select Scope', { - name: getExtensionDisplayName(selectedExtension, getCurrentLanguage()), + name: getExtensionDisplayName( + selectedExtension, + getCurrentLanguage(), + ), }) : t('Enable "{{name}}" - Select Scope', { - name: getExtensionDisplayName(selectedExtension, getCurrentLanguage()), + name: getExtensionDisplayName( + selectedExtension, + getCurrentLanguage(), + ), }); return ( diff --git a/packages/cli/src/ui/components/extensions/steps/UninstallConfirmStep.tsx b/packages/cli/src/ui/components/extensions/steps/UninstallConfirmStep.tsx index 8376a217fb1..a3622b9f9a0 100644 --- a/packages/cli/src/ui/components/extensions/steps/UninstallConfirmStep.tsx +++ b/packages/cli/src/ui/components/extensions/steps/UninstallConfirmStep.tsx @@ -60,7 +60,10 @@ export function UninstallConfirmStep({ {t('Are you sure you want to uninstall extension "{{name}}"?', { - name: getExtensionDisplayName(selectedExtension, getCurrentLanguage()), + name: getExtensionDisplayName( + selectedExtension, + getCurrentLanguage(), + ), })} diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 330d024cb44..cfa59d4506c 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -960,7 +960,9 @@ export const useSlashCommandProcessor = ( return { type: 'submit_prompt', content: mergedContent, - ...(firstModelOverride ? { modelOverride: firstModelOverride } : {}), + ...(firstModelOverride + ? { modelOverride: firstModelOverride } + : {}), ...(onCompleteCallbacks.length ? { onComplete: async () => { diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index d54675b4ddf..69c25a96d9a 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -566,7 +566,12 @@ export type HistoryItemDoctor = HistoryItemBase & { }; export type GoalStatusKind = - 'set' | 'achieved' | 'cleared' | 'failed' | 'aborted' | 'checking'; + | 'set' + | 'achieved' + | 'cleared' + | 'failed' + | 'aborted' + | 'checking'; export const TERMINAL_GOAL_STATUS_KINDS = [ 'achieved', diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 5c0558ec0b4..021eb1034d2 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -1525,7 +1525,6 @@ describe('GeminiChat', async () => { vi.mocked(mockConfig.getChatCompression).mockReturnValue({ maxRecentImagesToRetain: 1, imagePayloadThreshold: 1, - }); chat.setHistory([ { @@ -1544,7 +1543,6 @@ describe('GeminiChat', async () => { role: 'model', parts: [{ text: 'I see the second image' }], }, - ]); const response = (async function* () { yield { @@ -8063,7 +8061,6 @@ describe('GeminiChat', async () => { vi.mocked(mockConfig.getChatCompression).mockReturnValue({ maxRecentImagesToRetain: 0, imagePayloadThreshold: 1, - }); const streams = [ makeStream([makeChunk([{ text: 'initial' }], 'MAX_TOKENS')]), @@ -8140,7 +8137,6 @@ describe('GeminiChat', async () => { expect(text).toBe('Hello ending.'); }); - it('should coalesce overlapping recovery continuation text', async () => { const streams = [ makeStream([makeChunk([{ text: 'discarded initial' }], 'MAX_TOKENS')]), diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 3a60aef730f..1ddb3bcbd67 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -73,7 +73,6 @@ import { buildReattachParts, countAllInlineImages, replaceImagePayloadsInPlace, - } from '../services/image-payload-references.js'; import { estimateContentTokens, @@ -1576,7 +1575,6 @@ export class GeminiChat { return requestHistory; } return curatedHistory.map(copyContentContainer); - } /** diff --git a/packages/core/src/extension/npm.test.ts b/packages/core/src/extension/npm.test.ts index 8830fc3af6e..5958bff80ea 100644 --- a/packages/core/src/extension/npm.test.ts +++ b/packages/core/src/extension/npm.test.ts @@ -258,30 +258,28 @@ describe('downloadFromNpmRegistry', () => { ? { statusCode: 200, headers: {}, - on: vi.fn( - (event: string, handler: (data?: Buffer) => void) => { - if (event === 'data') { - handler( - Buffer.from( - JSON.stringify({ - 'dist-tags': { latest: '1.0.0' }, - versions: { - '1.0.0': { - dist: { - tarball: - 'HTTPS://registry.example.com/@scope/pkg/-/pkg-1.0.0.tgz', - }, + on: vi.fn((event: string, handler: (data?: Buffer) => void) => { + if (event === 'data') { + handler( + Buffer.from( + JSON.stringify({ + 'dist-tags': { latest: '1.0.0' }, + versions: { + '1.0.0': { + dist: { + tarball: + 'HTTPS://registry.example.com/@scope/pkg/-/pkg-1.0.0.tgz', }, }, - }), - ), - ); - } - if (event === 'end') { - handler(); - } - }, - ), + }, + }), + ), + ); + } + if (event === 'end') { + handler(); + } + }), } : { statusCode: 200, diff --git a/packages/core/src/hooks/sessionHooksManager.ts b/packages/core/src/hooks/sessionHooksManager.ts index 586bde3fc3d..14383587e7b 100644 --- a/packages/core/src/hooks/sessionHooksManager.ts +++ b/packages/core/src/hooks/sessionHooksManager.ts @@ -12,7 +12,8 @@ import type { FunctionHookCallback, HookConfig, HookExecutionResult, - HookEventName} from './types.js'; + HookEventName, +} from './types.js'; import { HookType } from './types.js'; import { getHookMatcherTarget, getToolMatcherTargets } from './hookPlanner.js'; diff --git a/packages/core/src/permissions/rule-parser.ts b/packages/core/src/permissions/rule-parser.ts index 532a0deb065..4b6eeead782 100644 --- a/packages/core/src/permissions/rule-parser.ts +++ b/packages/core/src/permissions/rule-parser.ts @@ -519,11 +519,7 @@ export function buildPermissionRules(ctx: PermissionCheckContext): string[] { // Only serialize stable, identity-bearing params — not volatile content // like `prompt` or `query`, which would make rules invocation-specific // and could leak sensitive data into settings.json. - const stableParamKeys = new Set([ - 'model', - 'subagent_type', - 'skill', - ]); + const stableParamKeys = new Set(['model', 'subagent_type', 'skill']); if (ctx.toolParams) { for (const key of stableParamKeys) { const v = ctx.toolParams[key]; diff --git a/packages/core/src/services/image-payload-references.test.ts b/packages/core/src/services/image-payload-references.test.ts index 3f365f09db1..9e4b27bdfc0 100644 --- a/packages/core/src/services/image-payload-references.test.ts +++ b/packages/core/src/services/image-payload-references.test.ts @@ -12,7 +12,6 @@ import { countAllInlineImages, prepareImagePayloadsForRequest, replaceImagePayloadsInPlace, - } from './image-payload-references.js'; function toolImageTurn(data: string): Content { @@ -298,4 +297,3 @@ describe('buildReattachParts', () => { expect(buildReattachParts(replaced, 0)).toEqual([]); }); }); - diff --git a/packages/core/src/services/image-payload-references.ts b/packages/core/src/services/image-payload-references.ts index ac9d7ccfa8f..4f01e11b2cf 100644 --- a/packages/core/src/services/image-payload-references.ts +++ b/packages/core/src/services/image-payload-references.ts @@ -138,7 +138,6 @@ export function buildReattachParts( ]; } - export function prepareImagePayloadsForRequest( contents: Content[], options: { diff --git a/packages/core/src/tools/client-mcp-registrar.test.ts b/packages/core/src/tools/client-mcp-registrar.test.ts index ff623928efd..96a22b6cde5 100644 --- a/packages/core/src/tools/client-mcp-registrar.test.ts +++ b/packages/core/src/tools/client-mcp-registrar.test.ts @@ -53,9 +53,10 @@ class InMemoryServerTransport { } /** Build a canned client-hosted MCP server exposing one echo tool. */ -function buildCannedServer( - sink: (message: JSONRPCMessage) => void, -): { transport: InMemoryServerTransport; ready: Promise } { +function buildCannedServer(sink: (message: JSONRPCMessage) => void): { + transport: InMemoryServerTransport; + ready: Promise; +} { const server = new McpServer({ name: 'chrome-tools', version: '0.0.1', diff --git a/packages/mobile-mcp/eslint.config.mjs b/packages/mobile-mcp/eslint.config.mjs index b004623993c..8109c883462 100644 --- a/packages/mobile-mcp/eslint.config.mjs +++ b/packages/mobile-mcp/eslint.config.mjs @@ -1,161 +1,165 @@ -import typescriptEslint from "@typescript-eslint/eslint-plugin"; -import tsParser from "@typescript-eslint/parser"; -import stylistic from "@stylistic/eslint-plugin"; -import importRules from "eslint-plugin-import"; +import typescriptEslint from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import stylistic from '@stylistic/eslint-plugin'; +import importRules from 'eslint-plugin-import'; const plugins = { - "@stylistic": stylistic, - "@typescript-eslint": typescriptEslint, - import: importRules, + '@stylistic': stylistic, + '@typescript-eslint': typescriptEslint, + import: importRules, }; export const baseRules = { - "@typescript-eslint/no-unused-vars": [ - 2, - {args: "none", caughtErrors: "none"}, - ], + '@typescript-eslint/no-unused-vars': [ + 2, + { args: 'none', caughtErrors: 'none' }, + ], - /** - * Enforced rules - */ - // syntax preferences - "object-curly-spacing": ["error", "always"], - quotes: [ - 2, - "double", - { - avoidEscape: true, - allowTemplateLiterals: true, - }, - ], - "jsx-quotes": [2, "prefer-single"], - "no-extra-semi": 2, - "@stylistic/semi": [2], - "comma-style": [2, "last"], - "wrap-iife": [2, "inside"], - "spaced-comment": [ - 2, - "always", - { - markers: ["*"], - }, - ], - eqeqeq: [2], - "accessor-pairs": [ - 2, - { - getWithoutSet: false, - setWithoutGet: false, - }, - ], - "brace-style": [2, "1tbs", {allowSingleLine: true}], - curly: [2, "all"], - "new-parens": 2, - "arrow-parens": [2, "as-needed"], - "prefer-const": 2, - "quote-props": [2, "consistent"], - "nonblock-statement-body-position": [2, "below"], + /** + * Enforced rules + */ + // syntax preferences + 'object-curly-spacing': ['error', 'always'], + quotes: [ + 2, + 'double', + { + avoidEscape: true, + allowTemplateLiterals: true, + }, + ], + 'jsx-quotes': [2, 'prefer-single'], + 'no-extra-semi': 2, + '@stylistic/semi': [2], + 'comma-style': [2, 'last'], + 'wrap-iife': [2, 'inside'], + 'spaced-comment': [ + 2, + 'always', + { + markers: ['*'], + }, + ], + eqeqeq: [2], + 'accessor-pairs': [ + 2, + { + getWithoutSet: false, + setWithoutGet: false, + }, + ], + 'brace-style': [2, '1tbs', { allowSingleLine: true }], + curly: [2, 'all'], + 'new-parens': 2, + 'arrow-parens': [2, 'as-needed'], + 'prefer-const': 2, + 'quote-props': [2, 'consistent'], + 'nonblock-statement-body-position': [2, 'below'], - // anti-patterns - "no-var": 2, - "no-with": 2, - "no-multi-str": 2, - "no-caller": 2, - "no-implied-eval": 2, - "no-labels": 2, - "no-new-object": 2, - "no-octal-escape": 2, - "no-self-compare": 2, - "no-shadow-restricted-names": 2, - "no-cond-assign": 2, - "no-debugger": 2, - "no-dupe-keys": 2, - "no-duplicate-case": 2, - "no-empty-character-class": 2, - "no-unreachable": 2, - "no-unsafe-negation": 2, - radix: 2, - "valid-typeof": 2, - "no-implicit-globals": [2], - "no-unused-expressions": [ - 2, - {allowShortCircuit: true, allowTernary: true, allowTaggedTemplates: true}, - ], - "no-proto": 2, + // anti-patterns + 'no-var': 2, + 'no-with': 2, + 'no-multi-str': 2, + 'no-caller': 2, + 'no-implied-eval': 2, + 'no-labels': 2, + 'no-new-object': 2, + 'no-octal-escape': 2, + 'no-self-compare': 2, + 'no-shadow-restricted-names': 2, + 'no-cond-assign': 2, + 'no-debugger': 2, + 'no-dupe-keys': 2, + 'no-duplicate-case': 2, + 'no-empty-character-class': 2, + 'no-unreachable': 2, + 'no-unsafe-negation': 2, + radix: 2, + 'valid-typeof': 2, + 'no-implicit-globals': [2], + 'no-unused-expressions': [ + 2, + { allowShortCircuit: true, allowTernary: true, allowTaggedTemplates: true }, + ], + 'no-proto': 2, - // es2015 features - "require-yield": 2, - "template-curly-spacing": [2, "never"], + // es2015 features + 'require-yield': 2, + 'template-curly-spacing': [2, 'never'], - // spacing details - "space-infix-ops": 2, - "space-in-parens": [2, "never"], - "array-bracket-spacing": [2, "never"], - "comma-spacing": [2, {before: false, after: true}], - "keyword-spacing": [2, "always"], - "space-before-function-paren": [ - 2, - { - anonymous: "never", - named: "never", - asyncArrow: "always", - }, - ], - "no-whitespace-before-property": 2, - "keyword-spacing": [ - 2, - { - overrides: { - if: {after: true}, - else: {after: true}, - for: {after: true}, - while: {after: true}, - do: {after: true}, - switch: {after: true}, - return: {after: true}, - }, - }, - ], - "arrow-spacing": [ - 2, - { - after: true, - before: true, - }, - ], - "@stylistic/func-call-spacing": 2, - "@stylistic/type-annotation-spacing": 2, + // spacing details + 'space-infix-ops': 2, + 'space-in-parens': [2, 'never'], + 'array-bracket-spacing': [2, 'never'], + 'comma-spacing': [2, { before: false, after: true }], + 'keyword-spacing': [2, 'always'], + 'space-before-function-paren': [ + 2, + { + anonymous: 'never', + named: 'never', + asyncArrow: 'always', + }, + ], + 'no-whitespace-before-property': 2, + 'keyword-spacing': [ + 2, + { + overrides: { + if: { after: true }, + else: { after: true }, + for: { after: true }, + while: { after: true }, + do: { after: true }, + switch: { after: true }, + return: { after: true }, + }, + }, + ], + 'arrow-spacing': [ + 2, + { + after: true, + before: true, + }, + ], + '@stylistic/func-call-spacing': 2, + '@stylistic/type-annotation-spacing': 2, - // file whitespace - "no-multiple-empty-lines": [2, {max: 2, maxEOF: 0}], - "no-mixed-spaces-and-tabs": 2, - "no-trailing-spaces": 2, - "linebreak-style": [process.platform === "win32" ? 0 : 2, "unix"], - indent: [ - 2, - "tab", - {SwitchCase: 1, CallExpression: {arguments: "first"}, MemberExpression: 1}, - ], - "key-spacing": [ - 2, - { - beforeColon: false, - }, - ], - "eol-last": 2, + // file whitespace + 'no-multiple-empty-lines': [2, { max: 2, maxEOF: 0 }], + 'no-mixed-spaces-and-tabs': 2, + 'no-trailing-spaces': 2, + 'linebreak-style': [process.platform === 'win32' ? 0 : 2, 'unix'], + indent: [ + 2, + 'tab', + { + SwitchCase: 1, + CallExpression: { arguments: 'first' }, + MemberExpression: 1, + }, + ], + 'key-spacing': [ + 2, + { + beforeColon: false, + }, + ], + 'eol-last': 2, }; const languageOptions = { - parser: tsParser, - ecmaVersion: 9, - sourceType: "module", + parser: tsParser, + ecmaVersion: 9, + sourceType: 'module', }; export default [ - { - files: ["**/*.ts"], - plugins, - languageOptions, - rules: baseRules, - }, + { + files: ['**/*.ts'], + plugins, + languageOptions, + rules: baseRules, + }, ]; diff --git a/packages/mobile-mcp/playwright.config.ts b/packages/mobile-mcp/playwright.config.ts index d07f6b48329..001ae03e800 100644 --- a/packages/mobile-mcp/playwright.config.ts +++ b/packages/mobile-mcp/playwright.config.ts @@ -1,17 +1,17 @@ -import { defineConfig } from "@playwright/test"; +import { defineConfig } from '@playwright/test'; // These are plain Node tests (no browser). Playwright is used purely as the // test runner, so no browser projects are configured. export default defineConfig({ - testDir: "./test", - testMatch: "*.ts", + testDir: './test', + testMatch: '*.ts', - // Device tests (android/ios/iphone-simulator) mutate real device state and - // must run serially, exactly as they did under mocha's single process. - workers: 1, - fullyParallel: false, + // Device tests (android/ios/iphone-simulator) mutate real device state and + // must run serially, exactly as they did under mocha's single process. + workers: 1, + fullyParallel: false, - // Device operations include several multi-second sleeps; the 30s default is - // too tight. - timeout: 60_000, + // Device operations include several multi-second sleeps; the 30s default is + // too tight. + timeout: 60_000, }); diff --git a/packages/mobile-mcp/src/image-utils.ts b/packages/mobile-mcp/src/image-utils.ts index e4ce5453909..9289fe14404 100644 --- a/packages/mobile-mcp/src/image-utils.ts +++ b/packages/mobile-mcp/src/image-utils.ts @@ -1,164 +1,180 @@ -import { execFileSync, spawnSync } from "child_process"; -import os from "node:os"; -import fs from "node:fs"; -import path from "node:path"; -import { trace } from "./logger"; +import { execFileSync, spawnSync } from 'child_process'; +import os from 'node:os'; +import fs from 'node:fs'; +import path from 'node:path'; +import { trace } from './logger'; const DEFAULT_JPEG_QUALITY = 75; export class ImageTransformer { - - private newWidth: number = 0; - private newFormat: "jpg" | "png" = "png"; - private jpegOptions: { quality: number } = { quality: DEFAULT_JPEG_QUALITY }; - - constructor(private buffer: Buffer) {} - - public resize(width: number): ImageTransformer { - this.newWidth = width; - return this; - } - - public jpeg(options: { quality: number }): ImageTransformer { - this.newFormat = "jpg"; - this.jpegOptions = options; - return this; - } - - public png(): ImageTransformer { - this.newFormat = "png"; - return this; - } - - public toBuffer(): Buffer { - if (isSipsInstalled()) { - try { - return this.toBufferWithSips(); - } catch (error) { - trace(`Sips failed, falling back to ImageMagick: ${error}`); - } - } - - try { - return this.toBufferWithImageMagick(); - } catch (error) { - trace(`ImageMagick failed: ${error}`); - throw new Error("Image scaling unavailable (requires Sips or ImageMagick)."); - } - } - - private qualityToSips(q: number): "low" | "normal" | "high" | "best" { - if (q >= 90) { - return "best"; - } - - if (q >= 75) { - return "high"; - } - - if (q >= 50) { - return "normal"; - } - - return "low"; - } - - private toBufferWithSips(): Buffer { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "image-")); - const inputFile = path.join(tempDir, "input"); - const outputFile = path.join(tempDir, `output.${this.newFormat === "jpg" ? "jpg" : "png"}`); - - try { - fs.writeFileSync(inputFile, this.buffer); - - const args = ["-s", "format", this.newFormat === "jpg" ? "jpeg" : "png"]; - if (this.newFormat === "jpg") { - args.push("-s", "formatOptions", this.qualityToSips(this.jpegOptions.quality)); - } - - args.push("-Z", `${this.newWidth}`); - args.push("--out", outputFile); - args.push(inputFile); - - trace(`Running sips command: /usr/bin/sips ${args.join(" ")}`); - const proc = spawnSync("/usr/bin/sips", args, { - maxBuffer: 8 * 1024 * 1024 - }); - - if (proc.status !== 0) { - throw new Error(`Sips failed with status ${proc.status}`); - } - - const outputBuffer = fs.readFileSync(outputFile); - trace("Sips returned buffer of size: " + outputBuffer.length); - return outputBuffer; - } finally { - try { - fs.rmSync(tempDir, { recursive: true, force: true }); - } catch (error) { - // Ignore cleanup errors - } - } - } - - private toBufferWithImageMagick(): Buffer { - const magickArgs = ["-", "-resize", `${this.newWidth}x`, "-quality", `${this.jpegOptions.quality}`, `${this.newFormat}:-`]; - trace(`Running magick command: magick ${magickArgs.join(" ")}`); - - const proc = spawnSync("magick", magickArgs, { - maxBuffer: 8 * 1024 * 1024, - input: this.buffer - }); - - return proc.stdout; - } + private newWidth: number = 0; + private newFormat: 'jpg' | 'png' = 'png'; + private jpegOptions: { quality: number } = { quality: DEFAULT_JPEG_QUALITY }; + + constructor(private buffer: Buffer) {} + + public resize(width: number): ImageTransformer { + this.newWidth = width; + return this; + } + + public jpeg(options: { quality: number }): ImageTransformer { + this.newFormat = 'jpg'; + this.jpegOptions = options; + return this; + } + + public png(): ImageTransformer { + this.newFormat = 'png'; + return this; + } + + public toBuffer(): Buffer { + if (isSipsInstalled()) { + try { + return this.toBufferWithSips(); + } catch (error) { + trace(`Sips failed, falling back to ImageMagick: ${error}`); + } + } + + try { + return this.toBufferWithImageMagick(); + } catch (error) { + trace(`ImageMagick failed: ${error}`); + throw new Error( + 'Image scaling unavailable (requires Sips or ImageMagick).', + ); + } + } + + private qualityToSips(q: number): 'low' | 'normal' | 'high' | 'best' { + if (q >= 90) { + return 'best'; + } + + if (q >= 75) { + return 'high'; + } + + if (q >= 50) { + return 'normal'; + } + + return 'low'; + } + + private toBufferWithSips(): Buffer { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-')); + const inputFile = path.join(tempDir, 'input'); + const outputFile = path.join( + tempDir, + `output.${this.newFormat === 'jpg' ? 'jpg' : 'png'}`, + ); + + try { + fs.writeFileSync(inputFile, this.buffer); + + const args = ['-s', 'format', this.newFormat === 'jpg' ? 'jpeg' : 'png']; + if (this.newFormat === 'jpg') { + args.push( + '-s', + 'formatOptions', + this.qualityToSips(this.jpegOptions.quality), + ); + } + + args.push('-Z', `${this.newWidth}`); + args.push('--out', outputFile); + args.push(inputFile); + + trace(`Running sips command: /usr/bin/sips ${args.join(' ')}`); + const proc = spawnSync('/usr/bin/sips', args, { + maxBuffer: 8 * 1024 * 1024, + }); + + if (proc.status !== 0) { + throw new Error(`Sips failed with status ${proc.status}`); + } + + const outputBuffer = fs.readFileSync(outputFile); + trace('Sips returned buffer of size: ' + outputBuffer.length); + return outputBuffer; + } finally { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch (error) { + // Ignore cleanup errors + } + } + } + + private toBufferWithImageMagick(): Buffer { + const magickArgs = [ + '-', + '-resize', + `${this.newWidth}x`, + '-quality', + `${this.jpegOptions.quality}`, + `${this.newFormat}:-`, + ]; + trace(`Running magick command: magick ${magickArgs.join(' ')}`); + + const proc = spawnSync('magick', magickArgs, { + maxBuffer: 8 * 1024 * 1024, + input: this.buffer, + }); + + return proc.stdout; + } } export class Image { - constructor(private buffer: Buffer) {} + constructor(private buffer: Buffer) {} - public static fromBuffer(buffer: Buffer): Image { - return new Image(buffer); - } + public static fromBuffer(buffer: Buffer): Image { + return new Image(buffer); + } - public resize(width: number): ImageTransformer { - return new ImageTransformer(this.buffer).resize(width); - } + public resize(width: number): ImageTransformer { + return new ImageTransformer(this.buffer).resize(width); + } - public jpeg(options: { quality: number }): ImageTransformer { - return new ImageTransformer(this.buffer).jpeg(options); - } + public jpeg(options: { quality: number }): ImageTransformer { + return new ImageTransformer(this.buffer).jpeg(options); + } } const isDarwin = (): boolean => { - return os.platform() === "darwin"; + return os.platform() === 'darwin'; }; export const isSipsInstalled = (): boolean => { - if (!isDarwin()) { - return false; - } - - try { - execFileSync("/usr/bin/sips", ["--version"]); - return true; - } catch (error) { - return false; - } + if (!isDarwin()) { + return false; + } + + try { + execFileSync('/usr/bin/sips', ['--version']); + return true; + } catch (error) { + return false; + } }; export const isImageMagickInstalled = (): boolean => { - try { - return execFileSync("magick", ["--version"]) - .toString() - .split("\n") - .filter(line => line.includes("Version: ImageMagick")) - .length > 0; - } catch (error) { - return false; - } + try { + return ( + execFileSync('magick', ['--version']) + .toString() + .split('\n') + .filter((line) => line.includes('Version: ImageMagick')).length > 0 + ); + } catch (error) { + return false; + } }; export const isScalingAvailable = (): boolean => { - return isImageMagickInstalled() || isSipsInstalled(); + return isImageMagickInstalled() || isSipsInstalled(); }; diff --git a/packages/mobile-mcp/src/index.ts b/packages/mobile-mcp/src/index.ts index d851fdf18fd..2ecc87c6207 100644 --- a/packages/mobile-mcp/src/index.ts +++ b/packages/mobile-mcp/src/index.ts @@ -1,132 +1,147 @@ #!/usr/bin/env node -import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { createMcpServer, getAgentVersion } from "./server"; -import { error } from "./logger"; -import express from "express"; -import { program } from "commander"; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { createMcpServer, getAgentVersion } from './server'; +import { error } from './logger'; +import express from 'express'; +import { program } from 'commander'; const startSseServer = async (host: string, port: number) => { - const app = express(); - const server = createMcpServer(); - - const authToken = process.env.MOBILEMCP_AUTH; - if (!authToken) { - error("WARNING: MOBILEMCP_AUTH is not set. The SSE server will accept unauthenticated connections. Set MOBILEMCP_AUTH to require Bearer token authentication."); - } - - if (authToken) { - app.use((req, res, next) => { - if (req.headers.authorization !== `Bearer ${authToken}`) { - res.status(401).json({ error: "Unauthorized" }); - return; - } - - next(); - }); - } - - // Block cross-origin requests — MCP clients are not browsers - app.use((req, res, next) => { - if (req.headers.origin) { - res.status(403).json({ error: "Cross-origin requests are not allowed" }); - return; - } - - if (req.method === "OPTIONS") { - res.status(403).end(); - return; - } - - next(); - }); - - let transport: SSEServerTransport | null = null; - - app.post("/mcp", (req, res) => { - if (transport) { - transport.handlePostMessage(req, res); - } - }); - - app.get("/mcp", (req, res) => { - if (transport) { - res.status(409).json({ error: "Another client is already connected. Disconnect the existing client first." }); - return; - } - - transport = new SSEServerTransport("/mcp", res); - - transport.onclose = () => { - transport = null; - }; - - server.connect(transport); - }); - - app.listen(port, host, () => { - error(`mobile-mcp ${getAgentVersion()} sse server listening on http://${host}:${port}/mcp`); - }); + const app = express(); + const server = createMcpServer(); + + const authToken = process.env.MOBILEMCP_AUTH; + if (!authToken) { + error( + 'WARNING: MOBILEMCP_AUTH is not set. The SSE server will accept unauthenticated connections. Set MOBILEMCP_AUTH to require Bearer token authentication.', + ); + } + + if (authToken) { + app.use((req, res, next) => { + if (req.headers.authorization !== `Bearer ${authToken}`) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + next(); + }); + } + + // Block cross-origin requests — MCP clients are not browsers + app.use((req, res, next) => { + if (req.headers.origin) { + res.status(403).json({ error: 'Cross-origin requests are not allowed' }); + return; + } + + if (req.method === 'OPTIONS') { + res.status(403).end(); + return; + } + + next(); + }); + + let transport: SSEServerTransport | null = null; + + app.post('/mcp', (req, res) => { + if (transport) { + transport.handlePostMessage(req, res); + } + }); + + app.get('/mcp', (req, res) => { + if (transport) { + res.status(409).json({ + error: + 'Another client is already connected. Disconnect the existing client first.', + }); + return; + } + + transport = new SSEServerTransport('/mcp', res); + + transport.onclose = () => { + transport = null; + }; + + server.connect(transport); + }); + + app.listen(port, host, () => { + error( + `mobile-mcp ${getAgentVersion()} sse server listening on http://${host}:${port}/mcp`, + ); + }); }; const startStdioServer = async () => { - try { - const transport = new StdioServerTransport(); - - const server = createMcpServer(); - await server.connect(transport); - - // Exit cleanly on termination signals so node flushes pending work - // (including NODE_V8_COVERAGE output). Node's default SIGINT/SIGTERM - // handling terminates the process without writing the coverage file, - // which makes the `test:mcp` report come back all zeros. - const shutdown = () => { - process.exit(0); - }; - - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); - - error("mobile-mcp server running on stdio"); - } catch (err: any) { - console.error("Fatal error in main():", err); - error("Fatal error in main(): " + JSON.stringify(err.stack)); - process.exit(1); - } + try { + const transport = new StdioServerTransport(); + + const server = createMcpServer(); + await server.connect(transport); + + // Exit cleanly on termination signals so node flushes pending work + // (including NODE_V8_COVERAGE output). Node's default SIGINT/SIGTERM + // handling terminates the process without writing the coverage file, + // which makes the `test:mcp` report come back all zeros. + const shutdown = () => { + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + error('mobile-mcp server running on stdio'); + } catch (err: any) { + console.error('Fatal error in main():', err); + error('Fatal error in main(): ' + JSON.stringify(err.stack)); + process.exit(1); + } }; const main = async () => { - program - .version(getAgentVersion()) - .option("--listen ", "Start SSE server on [host:]port") - .option("--stdio", "Start stdio server (default)") - .parse(process.argv); - - const options = program.opts(); - - if (options.listen) { - const listen = (options.listen as string).trim(); - const lastColon = listen.lastIndexOf(":"); - let host = "localhost"; - let rawPort: string; - - if (lastColon > 0) { - host = listen.substring(0, lastColon); - rawPort = listen.substring(lastColon + 1); - } else { - rawPort = listen; - } - - const port = Number.parseInt(rawPort, 10); - if (!host || !rawPort || !Number.isInteger(port) || port < 1 || port > 65535) { - error(`Invalid --listen value "${listen}". Expected [host:]port with port 1-65535.`); - process.exit(1); - } - - await startSseServer(host, port); - } else { - await startStdioServer(); - } + program + .version(getAgentVersion()) + .option('--listen ', 'Start SSE server on [host:]port') + .option('--stdio', 'Start stdio server (default)') + .parse(process.argv); + + const options = program.opts(); + + if (options.listen) { + const listen = (options.listen as string).trim(); + const lastColon = listen.lastIndexOf(':'); + let host = 'localhost'; + let rawPort: string; + + if (lastColon > 0) { + host = listen.substring(0, lastColon); + rawPort = listen.substring(lastColon + 1); + } else { + rawPort = listen; + } + + const port = Number.parseInt(rawPort, 10); + if ( + !host || + !rawPort || + !Number.isInteger(port) || + port < 1 || + port > 65535 + ) { + error( + `Invalid --listen value "${listen}". Expected [host:]port with port 1-65535.`, + ); + process.exit(1); + } + + await startSseServer(host, port); + } else { + await startStdioServer(); + } }; main().then(); diff --git a/packages/mobile-mcp/src/logger.ts b/packages/mobile-mcp/src/logger.ts index 3a760099460..89c3b86bbff 100644 --- a/packages/mobile-mcp/src/logger.ts +++ b/packages/mobile-mcp/src/logger.ts @@ -1,21 +1,21 @@ -import { appendFileSync } from "node:fs"; +import { appendFileSync } from 'node:fs'; const writeLog = (message: string) => { - if (process.env.LOG_FILE) { - const logfile = process.env.LOG_FILE; - const timestamp = new Date().toISOString(); - const levelStr = "INFO"; - const logMessage = `[${timestamp}] ${levelStr} ${message}`; - appendFileSync(logfile, logMessage + "\n"); - } + if (process.env.LOG_FILE) { + const logfile = process.env.LOG_FILE; + const timestamp = new Date().toISOString(); + const levelStr = 'INFO'; + const logMessage = `[${timestamp}] ${levelStr} ${message}`; + appendFileSync(logfile, logMessage + '\n'); + } - console.error(message); + console.error(message); }; export const trace = (message: string) => { - writeLog(message); + writeLog(message); }; export const error = (message: string) => { - writeLog(message); + writeLog(message); }; diff --git a/packages/mobile-mcp/src/mobilecli.ts b/packages/mobile-mcp/src/mobilecli.ts index f5394f84f8b..549997f5084 100644 --- a/packages/mobile-mcp/src/mobilecli.ts +++ b/packages/mobile-mcp/src/mobilecli.ts @@ -1,207 +1,246 @@ -import { existsSync } from "node:fs"; -import { dirname, join, sep } from "node:path"; -import { execFileSync, spawn, ChildProcess } from "node:child_process"; +import { existsSync } from 'node:fs'; +import { dirname, join, sep } from 'node:path'; +import { execFileSync, spawn, ChildProcess } from 'node:child_process'; export interface MobilecliCrashEntry { - processName: string; - timestamp: string; - id: string; + processName: string; + timestamp: string; + id: string; } export interface MobilecliCrashesListResponse { - status: "ok"; - data: MobilecliCrashEntry[]; + status: 'ok'; + data: MobilecliCrashEntry[]; } export interface MobilecliCrashGetResponse { - status: "ok"; - data: { - content: string; - id: string; - }; + status: 'ok'; + data: { + content: string; + id: string; + }; } export interface MobilecliAgentStatusResponse { - status: "ok" | "fail"; - data: { - message: string; - }; + status: 'ok' | 'fail'; + data: { + message: string; + }; } export interface MobilecliDevicesOptions { - includeOffline?: boolean; - platform?: "ios" | "android"; - type?: "real" | "emulator" | "simulator"; + includeOffline?: boolean; + platform?: 'ios' | 'android'; + type?: 'real' | 'emulator' | 'simulator'; } export interface MobilecliDeviceProvider { - type: string; // e.g. "mobilefleet" for remote devices - allocationId?: string; + type: string; // e.g. "mobilefleet" for remote devices + allocationId?: string; } export interface MobilecliDevice { - id: string; - name: string; - platform: "android" | "ios"; - type: "real" | "emulator" | "simulator"; - version: string; - provider?: MobilecliDeviceProvider; + id: string; + name: string; + platform: 'android' | 'ios'; + type: 'real' | 'emulator' | 'simulator'; + version: string; + provider?: MobilecliDeviceProvider; } export interface MobilecliDevicesResponse { - status: "ok"; - data: { - devices: MobilecliDevice[]; - }; + status: 'ok'; + data: { + devices: MobilecliDevice[]; + }; } const TIMEOUT = 30000; const MAX_BUFFER_SIZE = 1024 * 1024 * 8; export class Mobilecli { - private path: string | null = null; - - constructor() { } - - private getPath(): string { - if (!this.path) { - this.path = Mobilecli.getMobilecliPath(); - } - return this.path; - } - - public executeCommand(args: string[]): string { - const path = this.getPath(); - return execFileSync(path, args, { encoding: "utf8" }).toString().trim(); - } - - public spawnCommand(args: string[]): ChildProcess { - const binaryPath = this.getPath(); - return spawn(binaryPath, args, { - stdio: ["ignore", "ignore", "ignore"], - }); - } - - public executeCommandBuffer(args: string[]): Buffer { - const path = this.getPath(); - return execFileSync(path, args, { - encoding: "buffer", - maxBuffer: MAX_BUFFER_SIZE, - timeout: TIMEOUT, - }) as Buffer; - } - - private static getMobilecliPath(): string { - if (process.env.MOBILECLI_PATH) { - return process.env.MOBILECLI_PATH; - } - - const platform = process.platform; - const arch = process.arch; - - const normalizedPlatform = platform === "win32" ? "windows" : platform; - const normalizedArch = arch === "arm64" ? "arm64" : "amd64"; - const ext = platform === "win32" ? ".exe" : ""; - const binaryName = `mobilecli-${normalizedPlatform}-${normalizedArch}${ext}`; - - // Check if mobile-mcp is installed as a package - const currentPath = __filename; - const pathParts = currentPath.split(sep); - const lastNodeModulesIndex = pathParts.lastIndexOf("node_modules"); - - if (lastNodeModulesIndex !== -1) { - // We're inside node_modules, go to the last node_modules in the path - const nodeModulesParts = pathParts.slice(0, lastNodeModulesIndex + 1); - const lastNodeModulesPath = nodeModulesParts.join(sep); - const mobilecliPath = join(lastNodeModulesPath, "mobilecli", "bin", binaryName); - - if (existsSync(mobilecliPath)) { - return mobilecliPath; - } - } - - // Not in node_modules, look one directory up from current script - const scriptDir = dirname(__filename); - const parentDir = dirname(scriptDir); - const mobilecliPath = join(parentDir, "node_modules", "mobilecli", "bin", binaryName); - - if (existsSync(mobilecliPath)) { - return mobilecliPath; - } - - throw new Error(`Could not find mobilecli binary for platform: ${platform}`); - } - - getVersion(): string { - try { - const output = this.executeCommand(["--version"]); - if (output.startsWith("mobilecli version ")) { - return output.substring("mobilecli version ".length); - } - - return "failed"; - } catch (error: any) { - return "failed " + error.message; - } - } - - remoteListDevices(): string { - return this.executeCommand(["remote", "list-devices"]); - } - - remoteAllocate(platform: "ios" | "android"): string { - return this.executeCommand(["remote", "allocate", "--platform", platform]); - } - - remoteRelease(deviceId: string): string { - return this.executeCommand(["remote", "release", "--device", deviceId]); - } - - crashesList(deviceId: string): MobilecliCrashesListResponse { - const output = this.executeCommand(["device", "crashes", "list", "--device", deviceId]); - return JSON.parse(output) as MobilecliCrashesListResponse; - } - - crashesGet(deviceId: string, id: string): MobilecliCrashGetResponse { - const output = this.executeCommandBuffer(["device", "crashes", "get", id, "--device", deviceId]); - return JSON.parse(output.toString().trim()) as MobilecliCrashGetResponse; - } - - agentStatus(deviceId: string): MobilecliAgentStatusResponse { - const output = this.executeCommand(["agent", "status", "--device", deviceId]); - return JSON.parse(output) as MobilecliAgentStatusResponse; - } - - agentInstall(deviceId: string): void { - this.executeCommand(["agent", "install", "--device", deviceId]); - } - - getDevices(options?: MobilecliDevicesOptions): MobilecliDevicesResponse { - const args = ["devices"]; - - if (options) { - if (options.includeOffline) { - args.push("--include-offline"); - } - - if (options.platform) { - if (options.platform !== "ios" && options.platform !== "android") { - throw new Error(`Invalid platform: ${options.platform}. Must be "ios" or "android"`); - } - - args.push("--platform", options.platform); - } - - if (options.type) { - if (options.type !== "real" && options.type !== "emulator" && options.type !== "simulator") { - throw new Error(`Invalid type: ${options.type}. Must be "real", "emulator", or "simulator"`); - } - - args.push("--type", options.type); - } - } - - const mobilecliOutput = this.executeCommand(args); - return JSON.parse(mobilecliOutput) as MobilecliDevicesResponse; - } + private path: string | null = null; + + constructor() {} + + private getPath(): string { + if (!this.path) { + this.path = Mobilecli.getMobilecliPath(); + } + return this.path; + } + + public executeCommand(args: string[]): string { + const path = this.getPath(); + return execFileSync(path, args, { encoding: 'utf8' }).toString().trim(); + } + + public spawnCommand(args: string[]): ChildProcess { + const binaryPath = this.getPath(); + return spawn(binaryPath, args, { + stdio: ['ignore', 'ignore', 'ignore'], + }); + } + + public executeCommandBuffer(args: string[]): Buffer { + const path = this.getPath(); + return execFileSync(path, args, { + encoding: 'buffer', + maxBuffer: MAX_BUFFER_SIZE, + timeout: TIMEOUT, + }) as Buffer; + } + + private static getMobilecliPath(): string { + if (process.env.MOBILECLI_PATH) { + return process.env.MOBILECLI_PATH; + } + + const platform = process.platform; + const arch = process.arch; + + const normalizedPlatform = platform === 'win32' ? 'windows' : platform; + const normalizedArch = arch === 'arm64' ? 'arm64' : 'amd64'; + const ext = platform === 'win32' ? '.exe' : ''; + const binaryName = `mobilecli-${normalizedPlatform}-${normalizedArch}${ext}`; + + // Check if mobile-mcp is installed as a package + const currentPath = __filename; + const pathParts = currentPath.split(sep); + const lastNodeModulesIndex = pathParts.lastIndexOf('node_modules'); + + if (lastNodeModulesIndex !== -1) { + // We're inside node_modules, go to the last node_modules in the path + const nodeModulesParts = pathParts.slice(0, lastNodeModulesIndex + 1); + const lastNodeModulesPath = nodeModulesParts.join(sep); + const mobilecliPath = join( + lastNodeModulesPath, + 'mobilecli', + 'bin', + binaryName, + ); + + if (existsSync(mobilecliPath)) { + return mobilecliPath; + } + } + + // Not in node_modules, look one directory up from current script + const scriptDir = dirname(__filename); + const parentDir = dirname(scriptDir); + const mobilecliPath = join( + parentDir, + 'node_modules', + 'mobilecli', + 'bin', + binaryName, + ); + + if (existsSync(mobilecliPath)) { + return mobilecliPath; + } + + throw new Error( + `Could not find mobilecli binary for platform: ${platform}`, + ); + } + + getVersion(): string { + try { + const output = this.executeCommand(['--version']); + if (output.startsWith('mobilecli version ')) { + return output.substring('mobilecli version '.length); + } + + return 'failed'; + } catch (error: any) { + return 'failed ' + error.message; + } + } + + remoteListDevices(): string { + return this.executeCommand(['remote', 'list-devices']); + } + + remoteAllocate(platform: 'ios' | 'android'): string { + return this.executeCommand(['remote', 'allocate', '--platform', platform]); + } + + remoteRelease(deviceId: string): string { + return this.executeCommand(['remote', 'release', '--device', deviceId]); + } + + crashesList(deviceId: string): MobilecliCrashesListResponse { + const output = this.executeCommand([ + 'device', + 'crashes', + 'list', + '--device', + deviceId, + ]); + return JSON.parse(output) as MobilecliCrashesListResponse; + } + + crashesGet(deviceId: string, id: string): MobilecliCrashGetResponse { + const output = this.executeCommandBuffer([ + 'device', + 'crashes', + 'get', + id, + '--device', + deviceId, + ]); + return JSON.parse(output.toString().trim()) as MobilecliCrashGetResponse; + } + + agentStatus(deviceId: string): MobilecliAgentStatusResponse { + const output = this.executeCommand([ + 'agent', + 'status', + '--device', + deviceId, + ]); + return JSON.parse(output) as MobilecliAgentStatusResponse; + } + + agentInstall(deviceId: string): void { + this.executeCommand(['agent', 'install', '--device', deviceId]); + } + + getDevices(options?: MobilecliDevicesOptions): MobilecliDevicesResponse { + const args = ['devices']; + + if (options) { + if (options.includeOffline) { + args.push('--include-offline'); + } + + if (options.platform) { + if (options.platform !== 'ios' && options.platform !== 'android') { + throw new Error( + `Invalid platform: ${options.platform}. Must be "ios" or "android"`, + ); + } + + args.push('--platform', options.platform); + } + + if (options.type) { + if ( + options.type !== 'real' && + options.type !== 'emulator' && + options.type !== 'simulator' + ) { + throw new Error( + `Invalid type: ${options.type}. Must be "real", "emulator", or "simulator"`, + ); + } + + args.push('--type', options.type); + } + } + + const mobilecliOutput = this.executeCommand(args); + return JSON.parse(mobilecliOutput) as MobilecliDevicesResponse; + } } diff --git a/packages/mobile-mcp/src/png.ts b/packages/mobile-mcp/src/png.ts index dc87a9062ba..fc5e1a49008 100644 --- a/packages/mobile-mcp/src/png.ts +++ b/packages/mobile-mcp/src/png.ts @@ -1,20 +1,19 @@ export interface PngDimensions { - width: number; - height: number; + width: number; + height: number; } export class PNG { - public constructor(private readonly buffer: Buffer) { - } + public constructor(private readonly buffer: Buffer) {} - public getDimensions(): PngDimensions { - const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); - if (!this.buffer.subarray(0, 8).equals(pngSignature)) { - throw new Error("Not a valid PNG file"); - } + public getDimensions(): PngDimensions { + const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + if (!this.buffer.subarray(0, 8).equals(pngSignature)) { + throw new Error('Not a valid PNG file'); + } - const width = this.buffer.readUInt32BE(16); - const height = this.buffer.readUInt32BE(20); - return { width, height }; - } + const width = this.buffer.readUInt32BE(16); + const height = this.buffer.readUInt32BE(20); + return { width, height }; + } } diff --git a/packages/mobile-mcp/src/utils.ts b/packages/mobile-mcp/src/utils.ts index 9ba30337d4a..10bd06a0f71 100644 --- a/packages/mobile-mcp/src/utils.ts +++ b/packages/mobile-mcp/src/utils.ts @@ -1,88 +1,91 @@ -import path from "node:path"; -import os from "node:os"; -import fs from "node:fs"; -import { ActionableError } from "./robot"; +import path from 'node:path'; +import os from 'node:os'; +import fs from 'node:fs'; +import { ActionableError } from './robot'; export function validatePackageName(packageName: string): void { - if (!/^[a-zA-Z0-9._]+$/.test(packageName)) { - throw new ActionableError(`Invalid package name: "${packageName}"`); - } + if (!/^[a-zA-Z0-9._]+$/.test(packageName)) { + throw new ActionableError(`Invalid package name: "${packageName}"`); + } } export function validateLocale(locale: string): void { - if (!/^[a-zA-Z0-9,\- ]+$/.test(locale)) { - throw new ActionableError(`Invalid locale: "${locale}"`); - } + if (!/^[a-zA-Z0-9,\- ]+$/.test(locale)) { + throw new ActionableError(`Invalid locale: "${locale}"`); + } } function getAllowedRoots(): string[] { - const roots = [ - os.tmpdir(), - process.cwd(), - ]; - - // macOS /tmp is a symlink to /private/tmp, add both to be safe - if (process.platform === "darwin") { - roots.push("/tmp"); - roots.push("/private/tmp"); - } - - return roots.map(r => path.resolve(r)); + const roots = [os.tmpdir(), process.cwd()]; + + // macOS /tmp is a symlink to /private/tmp, add both to be safe + if (process.platform === 'darwin') { + roots.push('/tmp'); + roots.push('/private/tmp'); + } + + return roots.map((r) => path.resolve(r)); } function isPathUnderRoot(filePath: string, root: string): boolean { - const relative = path.relative(root, filePath); - if (relative === "") { - return false; - } + const relative = path.relative(root, filePath); + if (relative === '') { + return false; + } - if (path.isAbsolute(relative)) { - return false; - } + if (path.isAbsolute(relative)) { + return false; + } - if (relative.startsWith("..")) { - return false; - } + if (relative.startsWith('..')) { + return false; + } - return true; + return true; } -export function validateFileExtension(filePath: string, allowedExtensions: string[], toolName: string): void { - const ext = path.extname(filePath).toLowerCase(); - if (!allowedExtensions.includes(ext)) { - throw new ActionableError(`${toolName} requires a ${allowedExtensions.join(", ")} file extension, got: "${ext || "(none)"}"`); - } +export function validateFileExtension( + filePath: string, + allowedExtensions: string[], + toolName: string, +): void { + const ext = path.extname(filePath).toLowerCase(); + if (!allowedExtensions.includes(ext)) { + throw new ActionableError( + `${toolName} requires a ${allowedExtensions.join(', ')} file extension, got: "${ext || '(none)'}"`, + ); + } } function resolveWithSymlinks(filePath: string): string { - const resolved = path.resolve(filePath); - const dir = path.dirname(resolved); - const filename = path.basename(resolved); - - try { - return path.join(fs.realpathSync(dir), filename); - } catch { - return resolved; - } + const resolved = path.resolve(filePath); + const dir = path.dirname(resolved); + const filename = path.basename(resolved); + + try { + return path.join(fs.realpathSync(dir), filename); + } catch { + return resolved; + } } export function validateOutputPath(filePath: string): void { - const resolved = resolveWithSymlinks(filePath); - const allowedRoots = getAllowedRoots(); - const isWindows = process.platform === "win32"; - - const isAllowed = allowedRoots.some(root => { - if (isWindows) { - return isPathUnderRoot(resolved.toLowerCase(), root.toLowerCase()); - } - - return isPathUnderRoot(resolved, root); - }); - - if (!isAllowed) { - const dir = path.dirname(resolved); - throw new ActionableError( - `"${dir}" is not in the list of allowed directories. Allowed directories include the current directory and the temp directory on this host.` - ); - } + const resolved = resolveWithSymlinks(filePath); + const allowedRoots = getAllowedRoots(); + const isWindows = process.platform === 'win32'; + + const isAllowed = allowedRoots.some((root) => { + if (isWindows) { + return isPathUnderRoot(resolved.toLowerCase(), root.toLowerCase()); + } + + return isPathUnderRoot(resolved, root); + }); + + if (!isAllowed) { + const dir = path.dirname(resolved); + throw new ActionableError( + `"${dir}" is not in the list of allowed directories. Allowed directories include the current directory and the temp directory on this host.`, + ); + } } diff --git a/packages/mobile-mcp/src/webdriver-agent.ts b/packages/mobile-mcp/src/webdriver-agent.ts index a46a0c3fe3e..9a4c62f10b1 100644 --- a/packages/mobile-mcp/src/webdriver-agent.ts +++ b/packages/mobile-mcp/src/webdriver-agent.ts @@ -1,454 +1,494 @@ -import { ActionableError, SwipeDirection, ScreenSize, ScreenElement, Orientation } from "./robot"; +import { + ActionableError, + SwipeDirection, + ScreenSize, + ScreenElement, + Orientation, +} from './robot'; export interface SourceTreeElementRect { - x: number; - y: number; - width: number; - height: number; + x: number; + y: number; + width: number; + height: number; } export interface SourceTreeElement { - type: string; - label?: string; - name?: string; - value?: string; - rawIdentifier?: string; - rect: SourceTreeElementRect; - isVisible?: string; // "0" or "1" - children?: Array; + type: string; + label?: string; + name?: string; + value?: string; + rawIdentifier?: string; + rect: SourceTreeElementRect; + isVisible?: string; // "0" or "1" + children?: Array; } export interface SourceTree { - value: SourceTreeElement; + value: SourceTreeElement; } export class WebDriverAgent { - - constructor(private readonly host: string, private readonly port: number) { - } - - public async isRunning(): Promise { - const url = `http://${this.host}:${this.port}/status`; - try { - const response = await fetch(url); - const json = await response.json(); - return response.status === 200 && json.value?.ready === true; - } catch (error) { - // console.error(`Failed to connect to WebDriverAgent: ${error}`); - return false; - } - } - - public async createSession(): Promise { - const url = `http://${this.host}:${this.port}/session`; - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ capabilities: { alwaysMatch: { platformName: "iOS" } } }), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new ActionableError(`Failed to create WebDriver session: ${response.status} ${errorText}`); - } - - const json = await response.json(); - if (!json.value || !json.value.sessionId) { - throw new ActionableError(`Invalid session response: ${JSON.stringify(json)}`); - } - - return json.value.sessionId; - } - - public async deleteSession(sessionId: string) { - const url = `http://${this.host}:${this.port}/session/${sessionId}`; - const response = await fetch(url, { method: "DELETE" }); - return response.json(); - } - - public async withinSession(fn: (url: string) => Promise) { - const sessionId = await this.createSession(); - const url = `http://${this.host}:${this.port}/session/${sessionId}`; - const result = await fn(url); - await this.deleteSession(sessionId); - return result; - } - - public async getScreenSize(sessionUrl?: string): Promise { - if (sessionUrl) { - const url = `${sessionUrl}/wda/screen`; - const response = await fetch(url); - const json = await response.json(); - return { - width: json.value.screenSize.width, - height: json.value.screenSize.height, - scale: json.value.scale || 1, - }; - } else { - return this.withinSession(async sessionUrlInner => { - const url = `${sessionUrlInner}/wda/screen`; - const response = await fetch(url); - const json = await response.json(); - return { - width: json.value.screenSize.width, - height: json.value.screenSize.height, - scale: json.value.scale || 1, - }; - }); - } - } - - public async sendKeys(keys: string) { - await this.withinSession(async sessionUrl => { - const url = `${sessionUrl}/wda/keys`; - await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ value: [keys] }), - }); - }); - } - - public async pressButton(button: string) { - const _map = { - "HOME": "home", - "VOLUME_UP": "volumeup", - "VOLUME_DOWN": "volumedown", - }; - - if (button === "ENTER") { - await this.sendKeys("\n"); - return; - } - - // Type assertion to check if button is a key of _map - if (!(button in _map)) { - throw new ActionableError(`Button "${button}" is not supported`); - } - - await this.withinSession(async sessionUrl => { - const url = `${sessionUrl}/wda/pressButton`; - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: button, - }), - }); - - return response.json(); - }); - } - - public async tap(x: number, y: number) { - await this.withinSession(async sessionUrl => { - const url = `${sessionUrl}/actions`; - await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - actions: [ - { - type: "pointer", - id: "finger1", - parameters: { pointerType: "touch" }, - actions: [ - { type: "pointerMove", duration: 0, x, y }, - { type: "pointerDown", button: 0 }, - { type: "pause", duration: 100 }, - { type: "pointerUp", button: 0 } - ] - } - ] - }), - }); - }); - } - - public async doubleTap(x: number, y: number) { - await this.withinSession(async sessionUrl => { - const url = `${sessionUrl}/actions`; - await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - actions: [ - { - type: "pointer", - id: "finger1", - parameters: { pointerType: "touch" }, - actions: [ - { type: "pointerMove", duration: 0, x, y }, - { type: "pointerDown", button: 0 }, - { type: "pause", duration: 50 }, - { type: "pointerUp", button: 0 }, - - { type: "pause", duration: 100 }, - - { type: "pointerDown", button: 0 }, - { type: "pause", duration: 50 }, - { type: "pointerUp", button: 0 } - ] - } - ] - }), - }); - }); - } - - public async longPress(x: number, y: number, duration: number) { - await this.withinSession(async sessionUrl => { - const url = `${sessionUrl}/actions`; - await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - actions: [ - { - type: "pointer", - id: "finger1", - parameters: { pointerType: "touch" }, - actions: [ - { type: "pointerMove", duration: 0, x, y }, - { type: "pointerDown", button: 0 }, - { type: "pause", duration }, - { type: "pointerUp", button: 0 } - ] - } - ] - }), - }); - }); - } - - private isVisible(rect: SourceTreeElementRect): boolean { - return rect.x >= 0 && rect.y >= 0; - } - - private filterSourceElements(source: SourceTreeElement): Array { - const output: ScreenElement[] = []; - - const acceptedTypes = ["TextField", "Button", "Switch", "Icon", "SearchField", "StaticText", "Image"]; - - if (acceptedTypes.includes(source.type)) { - if (source.isVisible === "1" && this.isVisible(source.rect)) { - if (source.label !== null || source.name !== null || source.rawIdentifier !== null) { - output.push({ - type: source.type, - label: source.label, - name: source.name, - value: source.value, - identifier: source.rawIdentifier, - rect: { - x: source.rect.x, - y: source.rect.y, - width: source.rect.width, - height: source.rect.height, - }, - }); - } - } - } - - if (source.children) { - for (const child of source.children) { - output.push(...this.filterSourceElements(child)); - } - } - - return output; - } - - public async getPageSource(): Promise { - const url = `http://${this.host}:${this.port}/source/?format=json`; - const response = await fetch(url); - const json = await response.json(); - return json as SourceTree; - } - - public async getElementsOnScreen(): Promise { - const source = await this.getPageSource(); - return this.filterSourceElements(source.value); - } - - public async openUrl(url: string): Promise { - await this.withinSession(async sessionUrl => { - await fetch(`${sessionUrl}/url`, { - method: "POST", - body: JSON.stringify({ url }), - }); - }); - } - - public async getScreenshot(): Promise { - const url = `http://${this.host}:${this.port}/screenshot`; - const response = await fetch(url); - const json = await response.json(); - return Buffer.from(json.value, "base64"); - } - - public async swipe(direction: SwipeDirection): Promise { - await this.withinSession(async sessionUrl => { - const screenSize = await this.getScreenSize(sessionUrl); - let x0: number, y0: number, x1: number, y1: number; - // Use 60% of the width/height for swipe distance - const verticalDistance = Math.floor(screenSize.height * 0.6); - const horizontalDistance = Math.floor(screenSize.width * 0.6); - const centerX = Math.floor(screenSize.width / 2); - const centerY = Math.floor(screenSize.height / 2); - - switch (direction) { - case "up": - x0 = x1 = centerX; - y0 = centerY + Math.floor(verticalDistance / 2); - y1 = centerY - Math.floor(verticalDistance / 2); - break; - case "down": - x0 = x1 = centerX; - y0 = centerY - Math.floor(verticalDistance / 2); - y1 = centerY + Math.floor(verticalDistance / 2); - break; - case "left": - y0 = y1 = centerY; - x0 = centerX + Math.floor(horizontalDistance / 2); - x1 = centerX - Math.floor(horizontalDistance / 2); - break; - case "right": - y0 = y1 = centerY; - x0 = centerX - Math.floor(horizontalDistance / 2); - x1 = centerX + Math.floor(horizontalDistance / 2); - break; - default: - throw new ActionableError(`Swipe direction "${direction}" is not supported`); - } - - const url = `${sessionUrl}/actions`; - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - actions: [ - { - type: "pointer", - id: "finger1", - parameters: { pointerType: "touch" }, - actions: [ - { type: "pointerMove", duration: 0, x: x0, y: y0 }, - { type: "pointerDown", button: 0 }, - { type: "pointerMove", duration: 1000, x: x1, y: y1 }, - { type: "pointerUp", button: 0 } - ] - } - ] - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new ActionableError(`WebDriver actions request failed: ${response.status} ${errorText}`); - } - - // Clear actions to ensure they complete - await fetch(`${sessionUrl}/actions`, { - method: "DELETE", - }); - }); - } - - public async swipeFromCoordinate(x: number, y: number, direction: SwipeDirection, distance: number = 400): Promise { - await this.withinSession(async sessionUrl => { - // Use simple coordinates like the working swipe method - const x0 = x; - const y0 = y; - let x1 = x; - let y1 = y; - - // Calculate target position based on direction and distance - switch (direction) { - case "up": - y1 = y - distance; // Move up by specified distance - break; - case "down": - y1 = y + distance; // Move down by specified distance - break; - case "left": - x1 = x - distance; // Move left by specified distance - break; - case "right": - x1 = x + distance; // Move right by specified distance - break; - default: - throw new ActionableError(`Swipe direction "${direction}" is not supported`); - } - - const url = `${sessionUrl}/actions`; - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - actions: [ - { - type: "pointer", - id: "finger1", - parameters: { pointerType: "touch" }, - actions: [ - { type: "pointerMove", duration: 0, x: x0, y: y0 }, - { type: "pointerDown", button: 0 }, - { type: "pointerMove", duration: 1000, x: x1, y: y1 }, - { type: "pointerUp", button: 0 } - ] - } - ] - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new ActionableError(`WebDriver actions request failed: ${response.status} ${errorText}`); - } - - // Clear actions to ensure they complete - await fetch(`${sessionUrl}/actions`, { - method: "DELETE", - }); - }); - } - - public async setOrientation(orientation: Orientation): Promise { - await this.withinSession(async sessionUrl => { - const url = `${sessionUrl}/orientation`; - await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - orientation: orientation.toUpperCase() - }) - }); - }); - } - - public async getOrientation(): Promise { - return this.withinSession(async sessionUrl => { - const url = `${sessionUrl}/orientation`; - const response = await fetch(url); - const json = await response.json(); - return json.value.toLowerCase() as Orientation; - }); - } + constructor( + private readonly host: string, + private readonly port: number, + ) {} + + public async isRunning(): Promise { + const url = `http://${this.host}:${this.port}/status`; + try { + const response = await fetch(url); + const json = await response.json(); + return response.status === 200 && json.value?.ready === true; + } catch (error) { + // console.error(`Failed to connect to WebDriverAgent: ${error}`); + return false; + } + } + + public async createSession(): Promise { + const url = `http://${this.host}:${this.port}/session`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + capabilities: { alwaysMatch: { platformName: 'iOS' } }, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new ActionableError( + `Failed to create WebDriver session: ${response.status} ${errorText}`, + ); + } + + const json = await response.json(); + if (!json.value || !json.value.sessionId) { + throw new ActionableError( + `Invalid session response: ${JSON.stringify(json)}`, + ); + } + + return json.value.sessionId; + } + + public async deleteSession(sessionId: string) { + const url = `http://${this.host}:${this.port}/session/${sessionId}`; + const response = await fetch(url, { method: 'DELETE' }); + return response.json(); + } + + public async withinSession(fn: (url: string) => Promise) { + const sessionId = await this.createSession(); + const url = `http://${this.host}:${this.port}/session/${sessionId}`; + const result = await fn(url); + await this.deleteSession(sessionId); + return result; + } + + public async getScreenSize(sessionUrl?: string): Promise { + if (sessionUrl) { + const url = `${sessionUrl}/wda/screen`; + const response = await fetch(url); + const json = await response.json(); + return { + width: json.value.screenSize.width, + height: json.value.screenSize.height, + scale: json.value.scale || 1, + }; + } else { + return this.withinSession(async (sessionUrlInner) => { + const url = `${sessionUrlInner}/wda/screen`; + const response = await fetch(url); + const json = await response.json(); + return { + width: json.value.screenSize.width, + height: json.value.screenSize.height, + scale: json.value.scale || 1, + }; + }); + } + } + + public async sendKeys(keys: string) { + await this.withinSession(async (sessionUrl) => { + const url = `${sessionUrl}/wda/keys`; + await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ value: [keys] }), + }); + }); + } + + public async pressButton(button: string) { + const _map = { + HOME: 'home', + VOLUME_UP: 'volumeup', + VOLUME_DOWN: 'volumedown', + }; + + if (button === 'ENTER') { + await this.sendKeys('\n'); + return; + } + + // Type assertion to check if button is a key of _map + if (!(button in _map)) { + throw new ActionableError(`Button "${button}" is not supported`); + } + + await this.withinSession(async (sessionUrl) => { + const url = `${sessionUrl}/wda/pressButton`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: button, + }), + }); + + return response.json(); + }); + } + + public async tap(x: number, y: number) { + await this.withinSession(async (sessionUrl) => { + const url = `${sessionUrl}/actions`; + await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + actions: [ + { + type: 'pointer', + id: 'finger1', + parameters: { pointerType: 'touch' }, + actions: [ + { type: 'pointerMove', duration: 0, x, y }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: 100 }, + { type: 'pointerUp', button: 0 }, + ], + }, + ], + }), + }); + }); + } + + public async doubleTap(x: number, y: number) { + await this.withinSession(async (sessionUrl) => { + const url = `${sessionUrl}/actions`; + await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + actions: [ + { + type: 'pointer', + id: 'finger1', + parameters: { pointerType: 'touch' }, + actions: [ + { type: 'pointerMove', duration: 0, x, y }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: 50 }, + { type: 'pointerUp', button: 0 }, + + { type: 'pause', duration: 100 }, + + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: 50 }, + { type: 'pointerUp', button: 0 }, + ], + }, + ], + }), + }); + }); + } + + public async longPress(x: number, y: number, duration: number) { + await this.withinSession(async (sessionUrl) => { + const url = `${sessionUrl}/actions`; + await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + actions: [ + { + type: 'pointer', + id: 'finger1', + parameters: { pointerType: 'touch' }, + actions: [ + { type: 'pointerMove', duration: 0, x, y }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration }, + { type: 'pointerUp', button: 0 }, + ], + }, + ], + }), + }); + }); + } + + private isVisible(rect: SourceTreeElementRect): boolean { + return rect.x >= 0 && rect.y >= 0; + } + + private filterSourceElements( + source: SourceTreeElement, + ): Array { + const output: ScreenElement[] = []; + + const acceptedTypes = [ + 'TextField', + 'Button', + 'Switch', + 'Icon', + 'SearchField', + 'StaticText', + 'Image', + ]; + + if (acceptedTypes.includes(source.type)) { + if (source.isVisible === '1' && this.isVisible(source.rect)) { + if ( + source.label !== null || + source.name !== null || + source.rawIdentifier !== null + ) { + output.push({ + type: source.type, + label: source.label, + name: source.name, + value: source.value, + identifier: source.rawIdentifier, + rect: { + x: source.rect.x, + y: source.rect.y, + width: source.rect.width, + height: source.rect.height, + }, + }); + } + } + } + + if (source.children) { + for (const child of source.children) { + output.push(...this.filterSourceElements(child)); + } + } + + return output; + } + + public async getPageSource(): Promise { + const url = `http://${this.host}:${this.port}/source/?format=json`; + const response = await fetch(url); + const json = await response.json(); + return json as SourceTree; + } + + public async getElementsOnScreen(): Promise { + const source = await this.getPageSource(); + return this.filterSourceElements(source.value); + } + + public async openUrl(url: string): Promise { + await this.withinSession(async (sessionUrl) => { + await fetch(`${sessionUrl}/url`, { + method: 'POST', + body: JSON.stringify({ url }), + }); + }); + } + + public async getScreenshot(): Promise { + const url = `http://${this.host}:${this.port}/screenshot`; + const response = await fetch(url); + const json = await response.json(); + return Buffer.from(json.value, 'base64'); + } + + public async swipe(direction: SwipeDirection): Promise { + await this.withinSession(async (sessionUrl) => { + const screenSize = await this.getScreenSize(sessionUrl); + let x0: number, y0: number, x1: number, y1: number; + // Use 60% of the width/height for swipe distance + const verticalDistance = Math.floor(screenSize.height * 0.6); + const horizontalDistance = Math.floor(screenSize.width * 0.6); + const centerX = Math.floor(screenSize.width / 2); + const centerY = Math.floor(screenSize.height / 2); + + switch (direction) { + case 'up': + x0 = x1 = centerX; + y0 = centerY + Math.floor(verticalDistance / 2); + y1 = centerY - Math.floor(verticalDistance / 2); + break; + case 'down': + x0 = x1 = centerX; + y0 = centerY - Math.floor(verticalDistance / 2); + y1 = centerY + Math.floor(verticalDistance / 2); + break; + case 'left': + y0 = y1 = centerY; + x0 = centerX + Math.floor(horizontalDistance / 2); + x1 = centerX - Math.floor(horizontalDistance / 2); + break; + case 'right': + y0 = y1 = centerY; + x0 = centerX - Math.floor(horizontalDistance / 2); + x1 = centerX + Math.floor(horizontalDistance / 2); + break; + default: + throw new ActionableError( + `Swipe direction "${direction}" is not supported`, + ); + } + + const url = `${sessionUrl}/actions`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + actions: [ + { + type: 'pointer', + id: 'finger1', + parameters: { pointerType: 'touch' }, + actions: [ + { type: 'pointerMove', duration: 0, x: x0, y: y0 }, + { type: 'pointerDown', button: 0 }, + { type: 'pointerMove', duration: 1000, x: x1, y: y1 }, + { type: 'pointerUp', button: 0 }, + ], + }, + ], + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new ActionableError( + `WebDriver actions request failed: ${response.status} ${errorText}`, + ); + } + + // Clear actions to ensure they complete + await fetch(`${sessionUrl}/actions`, { + method: 'DELETE', + }); + }); + } + + public async swipeFromCoordinate( + x: number, + y: number, + direction: SwipeDirection, + distance: number = 400, + ): Promise { + await this.withinSession(async (sessionUrl) => { + // Use simple coordinates like the working swipe method + const x0 = x; + const y0 = y; + let x1 = x; + let y1 = y; + + // Calculate target position based on direction and distance + switch (direction) { + case 'up': + y1 = y - distance; // Move up by specified distance + break; + case 'down': + y1 = y + distance; // Move down by specified distance + break; + case 'left': + x1 = x - distance; // Move left by specified distance + break; + case 'right': + x1 = x + distance; // Move right by specified distance + break; + default: + throw new ActionableError( + `Swipe direction "${direction}" is not supported`, + ); + } + + const url = `${sessionUrl}/actions`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + actions: [ + { + type: 'pointer', + id: 'finger1', + parameters: { pointerType: 'touch' }, + actions: [ + { type: 'pointerMove', duration: 0, x: x0, y: y0 }, + { type: 'pointerDown', button: 0 }, + { type: 'pointerMove', duration: 1000, x: x1, y: y1 }, + { type: 'pointerUp', button: 0 }, + ], + }, + ], + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new ActionableError( + `WebDriver actions request failed: ${response.status} ${errorText}`, + ); + } + + // Clear actions to ensure they complete + await fetch(`${sessionUrl}/actions`, { + method: 'DELETE', + }); + }); + } + + public async setOrientation(orientation: Orientation): Promise { + await this.withinSession(async (sessionUrl) => { + const url = `${sessionUrl}/orientation`; + await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + orientation: orientation.toUpperCase(), + }), + }); + }); + } + + public async getOrientation(): Promise { + return this.withinSession(async (sessionUrl) => { + const url = `${sessionUrl}/orientation`; + const response = await fetch(url); + const json = await response.json(); + return json.value.toLowerCase() as Orientation; + }); + } } diff --git a/packages/mobile-mcp/test/android.ts b/packages/mobile-mcp/test/android.ts index a0e0516d50f..07c32bd601a 100644 --- a/packages/mobile-mcp/test/android.ts +++ b/packages/mobile-mcp/test/android.ts @@ -1,139 +1,159 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from '@playwright/test'; -import { PNG } from "../src/png"; -import { AndroidRobot, AndroidDeviceManager } from "../src/android"; +import { PNG } from '../src/png'; +import { AndroidRobot, AndroidDeviceManager } from '../src/android'; const manager = new AndroidDeviceManager(); const devices = manager.getConnectedDevices(); const hasOneAndroidDevice = devices.length === 1; -test.describe("android", () => { - - const android = new AndroidRobot(devices?.[0]?.deviceId || ""); - - test("should be able to get the screen size", async () => { - test.skip(!hasOneAndroidDevice, "requires exactly one android device"); - const screenSize = await android.getScreenSize(); - expect(screenSize.width).toBeGreaterThan(1024); - expect(screenSize.height).toBeGreaterThan(1024); - expect(screenSize.scale).toBe(1); - expect(Object.keys(screenSize).length, "screenSize should have exactly 3 properties").toBe(3); - }); - - test("should be able to take screenshot", async () => { - test.skip(!hasOneAndroidDevice, "requires exactly one android device"); - - const screenSize = await android.getScreenSize(); - const screenshot = await android.getScreenshot(); - expect(screenshot.length).toBeGreaterThan(64 * 1024); - - // must be a valid png image that matches the screen size - const image = new PNG(screenshot); - const pngSize = image.getDimensions(); - expect(pngSize.width).toBe(screenSize.width); - expect(pngSize.height).toBe(screenSize.height); - }); - - test("should be able to list apps", async () => { - test.skip(!hasOneAndroidDevice, "requires exactly one android device"); - const apps = await android.listApps(); - const packages = apps.map(app => app.packageName); - expect(packages).toContain("com.android.settings"); - }); - - test("should be able to open a url", async () => { - test.skip(!hasOneAndroidDevice, "requires exactly one android device"); - await android.adb("shell", "input", "keyevent", "HOME"); - await android.openUrl("https://www.example.com"); - }); - - test("should be able to list elements on screen", async () => { - test.skip(!hasOneAndroidDevice, "requires exactly one android device"); - await android.terminateApp("com.android.chrome"); - await android.adb("shell", "input", "keyevent", "HOME"); - await android.openUrl("https://www.example.com"); - const elements = await android.getElementsOnScreen(); - - // make sure title (TextView) is present - const foundTitle = elements.find(element => element.type === "android.widget.TextView" && element.text?.startsWith("This domain is for use in illustrative examples in documents")); - expect(foundTitle, "Title element not found").toBeTruthy(); - - // make sure navbar (EditText) is present - const foundNavbar = elements.find(element => element.type === "android.widget.EditText" && element.label === "Search or type URL" && element.text === "example.com"); - expect(foundNavbar, "Navbar element not found").toBeTruthy(); - - // this is an icon, but has accessibility label - const foundSecureIcon = elements.find(element => element.type === "android.widget.ImageButton" && element.text === "" && element.label === "New tab"); - expect(foundSecureIcon, "New tab icon not found").toBeTruthy(); - }); - - test("should be able to send keys and tap", async () => { - test.skip(!hasOneAndroidDevice, "requires exactly one android device"); - await android.terminateApp("com.google.android.deskclock"); - await android.adb("shell", "pm", "clear", "com.google.android.deskclock"); - await android.launchApp("com.google.android.deskclock"); - - // We probably start at Clock tab - await new Promise(resolve => setTimeout(resolve, 3000)); - let elements = await android.getElementsOnScreen(); - const timerElement = elements.find(e => e.label === "Timer" && e.type === "android.widget.FrameLayout"); - expect(timerElement).toBeDefined(); - await android.tap(timerElement.rect.x, timerElement.rect.y); - - // now we're in Timer tab - await new Promise(resolve => setTimeout(resolve, 3000)); - elements = await android.getElementsOnScreen(); - const currentTime = elements.find(e => e.text === "00h 00m 00s"); - expect(currentTime, "Expected time to be 00h 00m 00s").toBeDefined(); - await android.sendKeys("123456"); - - // now the title has changed with new timer - await new Promise(resolve => setTimeout(resolve, 3000)); - elements = await android.getElementsOnScreen(); - const newTime = elements.find(e => e.text === "12h 34m 56s"); - expect(newTime, "Expected time to be 12h 34m 56s").toBeDefined(); - - await android.terminateApp("com.google.android.deskclock"); - }); - - test("should be able to launch and terminate an app", async () => { - test.skip(!hasOneAndroidDevice, "requires exactly one android device"); - - // kill if running - await android.terminateApp("com.android.chrome"); - - await android.launchApp("com.android.chrome"); - await new Promise(resolve => setTimeout(resolve, 3000)); - const processes = await android.listRunningProcesses(); - expect(processes).toContain("com.android.chrome"); - - await android.terminateApp("com.android.chrome"); - const processes2 = await android.listRunningProcesses(); - expect(processes2).not.toContain("com.android.chrome"); - }); - - test("should handle orientation changes", async () => { - test.skip(!hasOneAndroidDevice, "requires exactly one android device"); - - // assume we start in portrait - const originalOrientation = await android.getOrientation(); - expect(originalOrientation).toBe("portrait"); - const screenSize1 = await android.getScreenSize(); - - // set to landscape - await android.setOrientation("landscape"); - await new Promise(resolve => setTimeout(resolve, 1500)); - const orientation = await android.getOrientation(); - expect(orientation).toBe("landscape"); - const screenSize2 = await android.getScreenSize(); - - // set to portrait - await android.setOrientation("portrait"); - await new Promise(resolve => setTimeout(resolve, 1500)); - const orientation2 = await android.getOrientation(); - expect(orientation2).toBe("portrait"); - - // screen size should not have changed - expect(screenSize1).toEqual(screenSize2); - }); +test.describe('android', () => { + const android = new AndroidRobot(devices?.[0]?.deviceId || ''); + + test('should be able to get the screen size', async () => { + test.skip(!hasOneAndroidDevice, 'requires exactly one android device'); + const screenSize = await android.getScreenSize(); + expect(screenSize.width).toBeGreaterThan(1024); + expect(screenSize.height).toBeGreaterThan(1024); + expect(screenSize.scale).toBe(1); + expect( + Object.keys(screenSize).length, + 'screenSize should have exactly 3 properties', + ).toBe(3); + }); + + test('should be able to take screenshot', async () => { + test.skip(!hasOneAndroidDevice, 'requires exactly one android device'); + + const screenSize = await android.getScreenSize(); + const screenshot = await android.getScreenshot(); + expect(screenshot.length).toBeGreaterThan(64 * 1024); + + // must be a valid png image that matches the screen size + const image = new PNG(screenshot); + const pngSize = image.getDimensions(); + expect(pngSize.width).toBe(screenSize.width); + expect(pngSize.height).toBe(screenSize.height); + }); + + test('should be able to list apps', async () => { + test.skip(!hasOneAndroidDevice, 'requires exactly one android device'); + const apps = await android.listApps(); + const packages = apps.map((app) => app.packageName); + expect(packages).toContain('com.android.settings'); + }); + + test('should be able to open a url', async () => { + test.skip(!hasOneAndroidDevice, 'requires exactly one android device'); + await android.adb('shell', 'input', 'keyevent', 'HOME'); + await android.openUrl('https://www.example.com'); + }); + + test('should be able to list elements on screen', async () => { + test.skip(!hasOneAndroidDevice, 'requires exactly one android device'); + await android.terminateApp('com.android.chrome'); + await android.adb('shell', 'input', 'keyevent', 'HOME'); + await android.openUrl('https://www.example.com'); + const elements = await android.getElementsOnScreen(); + + // make sure title (TextView) is present + const foundTitle = elements.find( + (element) => + element.type === 'android.widget.TextView' && + element.text?.startsWith( + 'This domain is for use in illustrative examples in documents', + ), + ); + expect(foundTitle, 'Title element not found').toBeTruthy(); + + // make sure navbar (EditText) is present + const foundNavbar = elements.find( + (element) => + element.type === 'android.widget.EditText' && + element.label === 'Search or type URL' && + element.text === 'example.com', + ); + expect(foundNavbar, 'Navbar element not found').toBeTruthy(); + + // this is an icon, but has accessibility label + const foundSecureIcon = elements.find( + (element) => + element.type === 'android.widget.ImageButton' && + element.text === '' && + element.label === 'New tab', + ); + expect(foundSecureIcon, 'New tab icon not found').toBeTruthy(); + }); + + test('should be able to send keys and tap', async () => { + test.skip(!hasOneAndroidDevice, 'requires exactly one android device'); + await android.terminateApp('com.google.android.deskclock'); + await android.adb('shell', 'pm', 'clear', 'com.google.android.deskclock'); + await android.launchApp('com.google.android.deskclock'); + + // We probably start at Clock tab + await new Promise((resolve) => setTimeout(resolve, 3000)); + let elements = await android.getElementsOnScreen(); + const timerElement = elements.find( + (e) => e.label === 'Timer' && e.type === 'android.widget.FrameLayout', + ); + expect(timerElement).toBeDefined(); + await android.tap(timerElement.rect.x, timerElement.rect.y); + + // now we're in Timer tab + await new Promise((resolve) => setTimeout(resolve, 3000)); + elements = await android.getElementsOnScreen(); + const currentTime = elements.find((e) => e.text === '00h 00m 00s'); + expect(currentTime, 'Expected time to be 00h 00m 00s').toBeDefined(); + await android.sendKeys('123456'); + + // now the title has changed with new timer + await new Promise((resolve) => setTimeout(resolve, 3000)); + elements = await android.getElementsOnScreen(); + const newTime = elements.find((e) => e.text === '12h 34m 56s'); + expect(newTime, 'Expected time to be 12h 34m 56s').toBeDefined(); + + await android.terminateApp('com.google.android.deskclock'); + }); + + test('should be able to launch and terminate an app', async () => { + test.skip(!hasOneAndroidDevice, 'requires exactly one android device'); + + // kill if running + await android.terminateApp('com.android.chrome'); + + await android.launchApp('com.android.chrome'); + await new Promise((resolve) => setTimeout(resolve, 3000)); + const processes = await android.listRunningProcesses(); + expect(processes).toContain('com.android.chrome'); + + await android.terminateApp('com.android.chrome'); + const processes2 = await android.listRunningProcesses(); + expect(processes2).not.toContain('com.android.chrome'); + }); + + test('should handle orientation changes', async () => { + test.skip(!hasOneAndroidDevice, 'requires exactly one android device'); + + // assume we start in portrait + const originalOrientation = await android.getOrientation(); + expect(originalOrientation).toBe('portrait'); + const screenSize1 = await android.getScreenSize(); + + // set to landscape + await android.setOrientation('landscape'); + await new Promise((resolve) => setTimeout(resolve, 1500)); + const orientation = await android.getOrientation(); + expect(orientation).toBe('landscape'); + const screenSize2 = await android.getScreenSize(); + + // set to portrait + await android.setOrientation('portrait'); + await new Promise((resolve) => setTimeout(resolve, 1500)); + const orientation2 = await android.getOrientation(); + expect(orientation2).toBe('portrait'); + + // screen size should not have changed + expect(screenSize1).toEqual(screenSize2); + }); }); diff --git a/packages/mobile-mcp/test/ios.ts b/packages/mobile-mcp/test/ios.ts index afb50cd7991..8cdacc99bb2 100644 --- a/packages/mobile-mcp/test/ios.ts +++ b/packages/mobile-mcp/test/ios.ts @@ -1,33 +1,34 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from '@playwright/test'; -import { IosManager, IosRobot } from "../src/ios"; -import { PNG } from "../src/png"; +import { IosManager, IosRobot } from '../src/ios'; +import { PNG } from '../src/png'; -test.describe("ios", () => { +test.describe('ios', () => { + let robot: IosRobot; + let hasOneDevice = false; - let robot: IosRobot; - let hasOneDevice = false; + test.beforeAll(async () => { + const manager = new IosManager(); + const devices = await manager.listDevices(); + hasOneDevice = devices.length === 1; + robot = new IosRobot(devices?.[0]?.deviceId || ''); + }); - test.beforeAll(async () => { - const manager = new IosManager(); - const devices = await manager.listDevices(); - hasOneDevice = devices.length === 1; - robot = new IosRobot(devices?.[0]?.deviceId || ""); - }); + test('should be able to get screenshot', async () => { + test.skip(!hasOneDevice, 'requires exactly one ios device'); + const screenshot = await robot.getScreenshot(); + // an black screenshot (screen is off) still consumes over 30KB + expect(screenshot.length).toBeGreaterThan(128 * 1024); - test("should be able to get screenshot", async () => { - test.skip(!hasOneDevice, "requires exactly one ios device"); - const screenshot = await robot.getScreenshot(); - // an black screenshot (screen is off) still consumes over 30KB - expect(screenshot.length).toBeGreaterThan(128 * 1024); + // must be a valid png image that matches the screen size + const image = new PNG(screenshot); + const pngSize = image.getDimensions(); + const screenSize = await robot.getScreenSize(); - // must be a valid png image that matches the screen size - const image = new PNG(screenshot); - const pngSize = image.getDimensions(); - const screenSize = await robot.getScreenSize(); - - // wda returns screen size as points, round up - expect(Math.ceil(pngSize.width / screenSize.scale)).toBe(screenSize.width); - expect(Math.ceil(pngSize.height / screenSize.scale)).toBe(screenSize.height); - }); + // wda returns screen size as points, round up + expect(Math.ceil(pngSize.width / screenSize.scale)).toBe(screenSize.width); + expect(Math.ceil(pngSize.height / screenSize.scale)).toBe( + screenSize.height, + ); + }); }); diff --git a/packages/mobile-mcp/test/iphone-simulator.ts b/packages/mobile-mcp/test/iphone-simulator.ts index 37644ad7837..193afd5d38e 100644 --- a/packages/mobile-mcp/test/iphone-simulator.ts +++ b/packages/mobile-mcp/test/iphone-simulator.ts @@ -1,167 +1,188 @@ -import { test, expect } from "@playwright/test"; -import { randomBytes } from "node:crypto"; - -import { PNG } from "../src/png"; -import { MobileDevice } from "../src/mobile-device"; -import { Mobilecli } from "../src/mobilecli"; - -test.describe("iphone-simulator", () => { - - const mobilecli = new Mobilecli(); - const devicesResponse = mobilecli.getDevices({ - platform: "ios", - type: "simulator", - includeOffline: false, - }); - - const bootedSimulators = devicesResponse.data.devices; - const hasOneSimulator = bootedSimulators.length >= 1; - const device = new MobileDevice(bootedSimulators?.[0]?.id || ""); - - const restartApp = async (app: string) => { - await device.launchApp(app); - await device.terminateApp(app); - await device.launchApp(app); - }; - - const restartPreferencesApp = async () => { - await restartApp("com.apple.Preferences"); - }; - - const restartRemindersApp = async () => { - await restartApp("com.apple.reminders"); - }; - - test("should be able to swipe", async () => { - test.skip(!hasOneSimulator, "requires a booted ios simulator"); - await restartPreferencesApp(); - - // make sure "General" is present (since it's at the top of the list) - const elements1 = await device.getElementsOnScreen(); - expect(elements1.findIndex(e => e.name === "com.apple.settings.general")).not.toBe(-1); - - // swipe up (bottom of screen to top of screen) - await device.swipe("up"); - - // make sure "General" is not visible now - const elements2 = await device.getElementsOnScreen(); - expect(elements2.findIndex(e => e.name === "com.apple.settings.general")).toBe(-1); - - // swipe down - await device.swipe("down"); - - // make sure "General" is visible again - const elements3 = await device.getElementsOnScreen(); - expect(elements3.findIndex(e => e.name === "com.apple.settings.general")).not.toBe(-1); - }); - - test("should be able to send keys and press enter", async () => { - test.skip(!hasOneSimulator, "requires a booted ios simulator"); - await restartRemindersApp(); - - // find new reminder element - await new Promise(resolve => setTimeout(resolve, 3000)); - const elements = await device.getElementsOnScreen(); - const newElement = elements.find(e => e.label === "New Reminder"); - expect(newElement, "should have found New Reminder element").toBeDefined(); - - // click on new reminder - await device.tap(newElement.rect.x, newElement.rect.y); - - // wait for keyboard to appear - await new Promise(resolve => setTimeout(resolve, 1000)); - - // send keys with press button "Enter" - const random1 = randomBytes(8).toString("hex"); - await device.sendKeys(random1); - await device.pressButton("ENTER"); - - // send keys with "\n" - const random2 = randomBytes(8).toString("hex"); - await device.sendKeys(random2 + "\n"); - - const elements2 = await device.getElementsOnScreen(); - expect(elements2.findIndex(e => e.value === random1)).not.toBe(-1); - expect(elements2.findIndex(e => e.value === random2)).not.toBe(-1); - }); - - test("should be able to get the screen size", async () => { - test.skip(!hasOneSimulator, "requires a booted ios simulator"); - const screenSize = await device.getScreenSize(); - expect(screenSize.width).toBeGreaterThan(256); - expect(screenSize.height).toBeGreaterThan(256); - expect(screenSize.scale).toBeGreaterThanOrEqual(1); - expect(Object.keys(screenSize).length, "screenSize should have exactly 3 properties").toBe(3); - }); - - test("should be able to get screenshot", async () => { - test.skip(!hasOneSimulator, "requires a booted ios simulator"); - const screenshot = await device.getScreenshot(); - expect(screenshot.length).toBeGreaterThan(64 * 1024); - - // must be a valid png image that matches the screen size - const image = new PNG(screenshot); - const pngSize = image.getDimensions(); - const screenSize = await device.getScreenSize(); - - // wda returns screen size as points, round up - expect(Math.ceil(pngSize.width / screenSize.scale)).toBe(screenSize.width); - expect(Math.ceil(pngSize.height / screenSize.scale)).toBe(screenSize.height); - }); - - test("should be able to open url", async () => { - test.skip(!hasOneSimulator, "requires a booted ios simulator"); - // simply checking thato openurl with https:// launches safari - await device.openUrl("https://www.example.com"); - await new Promise(resolve => setTimeout(resolve, 1000)); - - const elements = await device.getElementsOnScreen(); - expect(elements.length).toBeGreaterThan(0); - - const addressBar = elements.find(element => element.type === "TextField" && element.name === "TabBarItemTitle" && element.label === "Address"); - expect(addressBar, "should have address bar").toBeDefined(); - }); - - test("should be able to list apps", async () => { - test.skip(!hasOneSimulator, "requires a booted ios simulator"); - const apps = await device.listApps(); - const packages = apps.map(app => app.packageName); - expect(packages).toContain("com.apple.mobilesafari"); - expect(packages).toContain("com.apple.reminders"); - expect(packages).toContain("com.apple.Preferences"); - }); - - test("should be able to get elements on screen", async () => { - test.skip(!hasOneSimulator, "requires a booted ios simulator"); - await device.pressButton("HOME"); - await new Promise(resolve => setTimeout(resolve, 2000)); - - const elements = await device.getElementsOnScreen(); - expect(elements.length).toBeGreaterThan(0); - - // must have News app in home screen - const element = elements.find(e => e.type === "Icon" && e.label === "News"); - expect(element, "should have News app in home screen").toBeDefined(); - }); - - test("should be able to launch and terminate app", async () => { - test.skip(!hasOneSimulator, "requires a booted ios simulator"); - await restartPreferencesApp(); - await new Promise(resolve => setTimeout(resolve, 2000)); - const elements = await device.getElementsOnScreen(); - - const buttons = elements.filter(e => e.type === "Button").map(e => e.label); - expect(buttons).toContain("General"); - expect(buttons).toContain("Accessibility"); - - // make sure app is terminated - await device.terminateApp("com.apple.Preferences"); - const elements2 = await device.getElementsOnScreen(); - const buttons2 = elements2.filter(e => e.type === "Button").map(e => e.label); - expect(buttons2).not.toContain("General"); - }); - - /* +import { test, expect } from '@playwright/test'; +import { randomBytes } from 'node:crypto'; + +import { PNG } from '../src/png'; +import { MobileDevice } from '../src/mobile-device'; +import { Mobilecli } from '../src/mobilecli'; + +test.describe('iphone-simulator', () => { + const mobilecli = new Mobilecli(); + const devicesResponse = mobilecli.getDevices({ + platform: 'ios', + type: 'simulator', + includeOffline: false, + }); + + const bootedSimulators = devicesResponse.data.devices; + const hasOneSimulator = bootedSimulators.length >= 1; + const device = new MobileDevice(bootedSimulators?.[0]?.id || ''); + + const restartApp = async (app: string) => { + await device.launchApp(app); + await device.terminateApp(app); + await device.launchApp(app); + }; + + const restartPreferencesApp = async () => { + await restartApp('com.apple.Preferences'); + }; + + const restartRemindersApp = async () => { + await restartApp('com.apple.reminders'); + }; + + test('should be able to swipe', async () => { + test.skip(!hasOneSimulator, 'requires a booted ios simulator'); + await restartPreferencesApp(); + + // make sure "General" is present (since it's at the top of the list) + const elements1 = await device.getElementsOnScreen(); + expect( + elements1.findIndex((e) => e.name === 'com.apple.settings.general'), + ).not.toBe(-1); + + // swipe up (bottom of screen to top of screen) + await device.swipe('up'); + + // make sure "General" is not visible now + const elements2 = await device.getElementsOnScreen(); + expect( + elements2.findIndex((e) => e.name === 'com.apple.settings.general'), + ).toBe(-1); + + // swipe down + await device.swipe('down'); + + // make sure "General" is visible again + const elements3 = await device.getElementsOnScreen(); + expect( + elements3.findIndex((e) => e.name === 'com.apple.settings.general'), + ).not.toBe(-1); + }); + + test('should be able to send keys and press enter', async () => { + test.skip(!hasOneSimulator, 'requires a booted ios simulator'); + await restartRemindersApp(); + + // find new reminder element + await new Promise((resolve) => setTimeout(resolve, 3000)); + const elements = await device.getElementsOnScreen(); + const newElement = elements.find((e) => e.label === 'New Reminder'); + expect(newElement, 'should have found New Reminder element').toBeDefined(); + + // click on new reminder + await device.tap(newElement.rect.x, newElement.rect.y); + + // wait for keyboard to appear + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // send keys with press button "Enter" + const random1 = randomBytes(8).toString('hex'); + await device.sendKeys(random1); + await device.pressButton('ENTER'); + + // send keys with "\n" + const random2 = randomBytes(8).toString('hex'); + await device.sendKeys(random2 + '\n'); + + const elements2 = await device.getElementsOnScreen(); + expect(elements2.findIndex((e) => e.value === random1)).not.toBe(-1); + expect(elements2.findIndex((e) => e.value === random2)).not.toBe(-1); + }); + + test('should be able to get the screen size', async () => { + test.skip(!hasOneSimulator, 'requires a booted ios simulator'); + const screenSize = await device.getScreenSize(); + expect(screenSize.width).toBeGreaterThan(256); + expect(screenSize.height).toBeGreaterThan(256); + expect(screenSize.scale).toBeGreaterThanOrEqual(1); + expect( + Object.keys(screenSize).length, + 'screenSize should have exactly 3 properties', + ).toBe(3); + }); + + test('should be able to get screenshot', async () => { + test.skip(!hasOneSimulator, 'requires a booted ios simulator'); + const screenshot = await device.getScreenshot(); + expect(screenshot.length).toBeGreaterThan(64 * 1024); + + // must be a valid png image that matches the screen size + const image = new PNG(screenshot); + const pngSize = image.getDimensions(); + const screenSize = await device.getScreenSize(); + + // wda returns screen size as points, round up + expect(Math.ceil(pngSize.width / screenSize.scale)).toBe(screenSize.width); + expect(Math.ceil(pngSize.height / screenSize.scale)).toBe( + screenSize.height, + ); + }); + + test('should be able to open url', async () => { + test.skip(!hasOneSimulator, 'requires a booted ios simulator'); + // simply checking thato openurl with https:// launches safari + await device.openUrl('https://www.example.com'); + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const elements = await device.getElementsOnScreen(); + expect(elements.length).toBeGreaterThan(0); + + const addressBar = elements.find( + (element) => + element.type === 'TextField' && + element.name === 'TabBarItemTitle' && + element.label === 'Address', + ); + expect(addressBar, 'should have address bar').toBeDefined(); + }); + + test('should be able to list apps', async () => { + test.skip(!hasOneSimulator, 'requires a booted ios simulator'); + const apps = await device.listApps(); + const packages = apps.map((app) => app.packageName); + expect(packages).toContain('com.apple.mobilesafari'); + expect(packages).toContain('com.apple.reminders'); + expect(packages).toContain('com.apple.Preferences'); + }); + + test('should be able to get elements on screen', async () => { + test.skip(!hasOneSimulator, 'requires a booted ios simulator'); + await device.pressButton('HOME'); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const elements = await device.getElementsOnScreen(); + expect(elements.length).toBeGreaterThan(0); + + // must have News app in home screen + const element = elements.find( + (e) => e.type === 'Icon' && e.label === 'News', + ); + expect(element, 'should have News app in home screen').toBeDefined(); + }); + + test('should be able to launch and terminate app', async () => { + test.skip(!hasOneSimulator, 'requires a booted ios simulator'); + await restartPreferencesApp(); + await new Promise((resolve) => setTimeout(resolve, 2000)); + const elements = await device.getElementsOnScreen(); + + const buttons = elements + .filter((e) => e.type === 'Button') + .map((e) => e.label); + expect(buttons).toContain('General'); + expect(buttons).toContain('Accessibility'); + + // make sure app is terminated + await device.terminateApp('com.apple.Preferences'); + const elements2 = await device.getElementsOnScreen(); + const buttons2 = elements2 + .filter((e) => e.type === 'Button') + .map((e) => e.label); + expect(buttons2).not.toContain('General'); + }); + + /* test("should be able to get and set orientation", async () => { test.skip(!hasOneSimulator, "requires a booted ios simulator"); @@ -182,8 +203,10 @@ test.describe("iphone-simulator", () => { }); */ - test("should throw an error if button is not supported", async () => { - test.skip(!hasOneSimulator, "requires a booted ios simulator"); - await expect(device.pressButton("NOT_A_BUTTON" as any)).rejects.toThrow("unsupported button: NOT_A_BUTTON"); - }); + test('should throw an error if button is not supported', async () => { + test.skip(!hasOneSimulator, 'requires a booted ios simulator'); + await expect(device.pressButton('NOT_A_BUTTON' as any)).rejects.toThrow( + 'unsupported button: NOT_A_BUTTON', + ); + }); }); diff --git a/packages/mobile-mcp/test/mobilecli.test.ts b/packages/mobile-mcp/test/mobilecli.test.ts index 453c22f67c2..b4e86fe6518 100644 --- a/packages/mobile-mcp/test/mobilecli.test.ts +++ b/packages/mobile-mcp/test/mobilecli.test.ts @@ -1,119 +1,136 @@ -import { test, expect } from "@playwright/test"; -import { Mobilecli } from "../src/mobilecli"; +import { test, expect } from '@playwright/test'; +import { Mobilecli } from '../src/mobilecli'; type ExecuteCommandCall = { - args: string[]; + args: string[]; }; -function createMockMobilecli(mockResponse: string): { mobilecli: Mobilecli; calls: ExecuteCommandCall[] } { - const mobilecli = new Mobilecli(); - const calls: ExecuteCommandCall[] = []; +function createMockMobilecli(mockResponse: string): { + mobilecli: Mobilecli; + calls: ExecuteCommandCall[]; +} { + const mobilecli = new Mobilecli(); + const calls: ExecuteCommandCall[] = []; - mobilecli.executeCommand = function(args: string[]): string { - calls.push({ args }); - return mockResponse; - }; + mobilecli.executeCommand = function (args: string[]): string { + calls.push({ args }); + return mockResponse; + }; - return { mobilecli, calls }; + return { mobilecli, calls }; } -test.describe("mobilecli", () => { - - const mobilecli = new Mobilecli(); - - test.describe("getVersion", () => { - test("should return a version string", () => { - const version = mobilecli.getVersion(); - expect(version.length).toBeGreaterThan(0); - expect(version).not.toContain("failed"); - }); - - test("should return version in correct format", () => { - const version = mobilecli.getVersion(); - // Version should be in format like "0.0.45" or similar - const versionPattern = /^\d+\.\d+\.\d+/; - expect(version, `Version "${version}" should match pattern X.Y.Z`).toMatch(versionPattern); - }); - - test("should return failed when MOBILECLI_PATH points to invalid location", () => { - try { - process.env.MOBILECLI_PATH = "/tmp"; - const mobilecli = new Mobilecli(); - const version = mobilecli.getVersion(); - expect(version, `Expected version to include "failed" but got: ${version}`).toContain("failed"); - } finally { - delete process.env.MOBILECLI_PATH; - } - }); - - test("should call executeCommand with --version argument", () => { - const { mobilecli, calls } = createMockMobilecli("mobilecli version 1.0.0"); - const version = mobilecli.getVersion(); - - expect(calls.length).toBe(1); - expect(calls[0].args).toEqual(["--version"]); - expect(version).toBe("1.0.0"); - }); - }); - - test.describe("getDevices", () => { - const mockDevicesResponse = JSON.stringify({ - status: "ok", - data: { - devices: [ - { - id: "device1", - name: "Test Device", - platform: "ios", - type: "simulator", - version: "17.0" - } - ] - } - }); - - test("should call executeCommand with devices argument when no options", () => { - const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse); - mobilecli.getDevices(); - - expect(calls.length).toBe(1); - expect(calls[0].args).toEqual(["devices"]); - }); - - test("should call executeCommand with platform filter", () => { - const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse); - mobilecli.getDevices({ platform: "ios" }); - - expect(calls.length).toBe(1); - expect(calls[0].args).toEqual(["devices", "--platform", "ios"]); - }); - - test("should call executeCommand with type filter", () => { - const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse); - mobilecli.getDevices({ type: "simulator" }); - - expect(calls.length).toBe(1); - expect(calls[0].args).toEqual(["devices", "--type", "simulator"]); - }); - - test("should call executeCommand with includeOffline flag", () => { - const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse); - mobilecli.getDevices({ includeOffline: true }); - - expect(calls.length).toBe(1); - expect(calls[0].args).toEqual(["devices", "--include-offline"]); - }); - - test("should call executeCommand with combined options", () => { - const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse); - mobilecli.getDevices({ - platform: "android", - type: "emulator", - includeOffline: true - }); - - expect(calls.length).toBe(1); - expect(calls[0].args).toEqual(["devices", "--include-offline", "--platform", "android", "--type", "emulator"]); - }); - }); +test.describe('mobilecli', () => { + const mobilecli = new Mobilecli(); + + test.describe('getVersion', () => { + test('should return a version string', () => { + const version = mobilecli.getVersion(); + expect(version.length).toBeGreaterThan(0); + expect(version).not.toContain('failed'); + }); + + test('should return version in correct format', () => { + const version = mobilecli.getVersion(); + // Version should be in format like "0.0.45" or similar + const versionPattern = /^\d+\.\d+\.\d+/; + expect( + version, + `Version "${version}" should match pattern X.Y.Z`, + ).toMatch(versionPattern); + }); + + test('should return failed when MOBILECLI_PATH points to invalid location', () => { + try { + process.env.MOBILECLI_PATH = '/tmp'; + const mobilecli = new Mobilecli(); + const version = mobilecli.getVersion(); + expect( + version, + `Expected version to include "failed" but got: ${version}`, + ).toContain('failed'); + } finally { + delete process.env.MOBILECLI_PATH; + } + }); + + test('should call executeCommand with --version argument', () => { + const { mobilecli, calls } = createMockMobilecli( + 'mobilecli version 1.0.0', + ); + const version = mobilecli.getVersion(); + + expect(calls.length).toBe(1); + expect(calls[0].args).toEqual(['--version']); + expect(version).toBe('1.0.0'); + }); + }); + + test.describe('getDevices', () => { + const mockDevicesResponse = JSON.stringify({ + status: 'ok', + data: { + devices: [ + { + id: 'device1', + name: 'Test Device', + platform: 'ios', + type: 'simulator', + version: '17.0', + }, + ], + }, + }); + + test('should call executeCommand with devices argument when no options', () => { + const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse); + mobilecli.getDevices(); + + expect(calls.length).toBe(1); + expect(calls[0].args).toEqual(['devices']); + }); + + test('should call executeCommand with platform filter', () => { + const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse); + mobilecli.getDevices({ platform: 'ios' }); + + expect(calls.length).toBe(1); + expect(calls[0].args).toEqual(['devices', '--platform', 'ios']); + }); + + test('should call executeCommand with type filter', () => { + const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse); + mobilecli.getDevices({ type: 'simulator' }); + + expect(calls.length).toBe(1); + expect(calls[0].args).toEqual(['devices', '--type', 'simulator']); + }); + + test('should call executeCommand with includeOffline flag', () => { + const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse); + mobilecli.getDevices({ includeOffline: true }); + + expect(calls.length).toBe(1); + expect(calls[0].args).toEqual(['devices', '--include-offline']); + }); + + test('should call executeCommand with combined options', () => { + const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse); + mobilecli.getDevices({ + platform: 'android', + type: 'emulator', + includeOffline: true, + }); + + expect(calls.length).toBe(1); + expect(calls[0].args).toEqual([ + 'devices', + '--include-offline', + '--platform', + 'android', + '--type', + 'emulator', + ]); + }); + }); }); diff --git a/packages/mobile-mcp/test/png.ts b/packages/mobile-mcp/test/png.ts index 46434381cb5..521fa251758 100644 --- a/packages/mobile-mcp/test/png.ts +++ b/packages/mobile-mcp/test/png.ts @@ -1,18 +1,18 @@ -import { test, expect } from "@playwright/test"; -import { PNG } from "../src/png"; +import { test, expect } from '@playwright/test'; +import { PNG } from '../src/png'; +test.describe('png', () => { + test('should be able to parse png', () => { + const buffer = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII='; + const png = new PNG(Buffer.from(buffer, 'base64')); + expect(png.getDimensions().width).toBe(1); + expect(png.getDimensions().height).toBe(1); + }); -test.describe("png", () => { - test("should be able to parse png", () => { - const buffer = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII="; - const png = new PNG(Buffer.from(buffer, "base64")); - expect(png.getDimensions().width).toBe(1); - expect(png.getDimensions().height).toBe(1); - }); - - test("should be able to detect an invalid png", () => { - const buffer = btoa("IAMADUCKIAMADUCKIAMADUCKIAMADUCKIAMADUCK"); - const png = new PNG(Buffer.from(buffer, "base64")); - expect(() => png.getDimensions()).toThrow(); - }); + test('should be able to detect an invalid png', () => { + const buffer = btoa('IAMADUCKIAMADUCKIAMADUCKIAMADUCKIAMADUCK'); + const png = new PNG(Buffer.from(buffer, 'base64')); + expect(() => png.getDimensions()).toThrow(); + }); }); diff --git a/packages/mobile-mcp/tsconfig.json b/packages/mobile-mcp/tsconfig.json index 04116e19946..68b4f3db415 100644 --- a/packages/mobile-mcp/tsconfig.json +++ b/packages/mobile-mcp/tsconfig.json @@ -8,7 +8,5 @@ "module": "CommonJS", "outDir": "./lib" }, - "include": [ - "src", - ], -} \ No newline at end of file + "include": ["src"] +} diff --git a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx index 978e00aa3c2..4ea149e19e1 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.test.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.test.tsx @@ -151,7 +151,11 @@ describe('deriveSessionCards', () => { ]; const status = [statusSession('s-appr', { pendingPermissionCount: 1 })]; const cards = deriveSessionCards(sessions, status, 's-run'); - expect(cards.map((c) => c.sessionId)).toEqual(['s-appr', 's-run', 's-idle']); + expect(cards.map((c) => c.sessionId)).toEqual([ + 's-appr', + 's-run', + 's-idle', + ]); expect(cards.map((c) => c.status)).toEqual([ 'needsApproval', 'running', @@ -214,7 +218,9 @@ describe('SessionOverviewPanel', () => { session('s-appr', { displayName: 'Charlie' }), ]; statusState.report = { - full: { sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })] }, + full: { + sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })], + }, }; render(); expect(cardLabels()).toEqual(['Charlie', 'Alpha', 'Bravo']); @@ -243,7 +249,9 @@ describe('SessionOverviewPanel', () => { session('s-appr', { displayName: 'Charlie' }), ]; statusState.report = { - full: { sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })] }, + full: { + sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })], + }, }; render(); const selectAll = container!.querySelector( @@ -284,7 +292,9 @@ describe('SessionOverviewPanel', () => { session('s-appr', { displayName: 'Charlie' }), ]; statusState.report = { - full: { sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })] }, + full: { + sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })], + }, }; const onOpenSplit = vi.fn(); render({ onOpenSplit }); diff --git a/packages/web-shell/client/components/SessionOverviewPanel.tsx b/packages/web-shell/client/components/SessionOverviewPanel.tsx index 7e6be114a4b..74a16a46db9 100644 --- a/packages/web-shell/client/components/SessionOverviewPanel.tsx +++ b/packages/web-shell/client/components/SessionOverviewPanel.tsx @@ -143,9 +143,8 @@ function SessionOverviewPanelInner({ const connection = useConnection(); const currentSessionId = connection.sessionId; const organizationEnabled = - connection.capabilities?.features?.includes( - SESSION_ORGANIZATION_FEATURE, - ) ?? false; + connection.capabilities?.features?.includes(SESSION_ORGANIZATION_FEATURE) ?? + false; const { sessions, loading, error, reload } = useSessions({ autoLoad: true, @@ -388,7 +387,9 @@ function SessionOverviewPanelInner({ )}
- + {t(`sessionsOverview.status.${card.status}`)} {card.model && ( diff --git a/packages/web-shell/client/components/dialogs/SvgLineChart.test.tsx b/packages/web-shell/client/components/dialogs/SvgLineChart.test.tsx index 7f68bfb46e8..b124be1cc7b 100644 --- a/packages/web-shell/client/components/dialogs/SvgLineChart.test.tsx +++ b/packages/web-shell/client/components/dialogs/SvgLineChart.test.tsx @@ -72,7 +72,9 @@ describe('SvgLineChart', () => { it('places a single-point series mid-width so its dot still renders', () => { const el = render( - , + , ); // one moveto point, no line segment, but a visible dot const d = el.querySelector('path')?.getAttribute('d') ?? ''; diff --git a/packages/web-shell/client/hooks/useIsLargeScreen.test.tsx b/packages/web-shell/client/hooks/useIsLargeScreen.test.tsx index 8906aa20514..20dce9ce65c 100644 --- a/packages/web-shell/client/hooks/useIsLargeScreen.test.tsx +++ b/packages/web-shell/client/hooks/useIsLargeScreen.test.tsx @@ -33,8 +33,10 @@ function installMatchMedia(initial: boolean) { }, media: '', onchange: null, - addEventListener: (_type: string, cb: (event: MediaQueryListEvent) => void) => - listeners.push(cb), + addEventListener: ( + _type: string, + cb: (event: MediaQueryListEvent) => void, + ) => listeners.push(cb), removeEventListener: ( _type: string, cb: (event: MediaQueryListEvent) => void, @@ -45,7 +47,9 @@ function installMatchMedia(initial: boolean) { removeListener: () => {}, dispatchEvent: () => true, }; - window.matchMedia = vi.fn().mockReturnValue(mql) as unknown as typeof window.matchMedia; + window.matchMedia = vi + .fn() + .mockReturnValue(mql) as unknown as typeof window.matchMedia; return { set(next: boolean) { matches = next; @@ -69,7 +73,9 @@ function render(): void { } function value(): string | undefined { - return container?.querySelector('[data-testid="value"]')?.textContent ?? undefined; + return ( + container?.querySelector('[data-testid="value"]')?.textContent ?? undefined + ); } describe('useIsLargeScreen', () => { diff --git a/packages/web-shell/client/hooks/useIsLargeScreen.ts b/packages/web-shell/client/hooks/useIsLargeScreen.ts index 38aad79e8b2..97037dce9b2 100644 --- a/packages/web-shell/client/hooks/useIsLargeScreen.ts +++ b/packages/web-shell/client/hooks/useIsLargeScreen.ts @@ -27,7 +27,10 @@ export function useIsLargeScreen(query: string = LARGE_SCREEN_QUERY): boolean { const [isLarge, setIsLarge] = useState(() => matchesQuery(query)); useEffect(() => { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + if ( + typeof window === 'undefined' || + typeof window.matchMedia !== 'function' + ) { return; } const mql = window.matchMedia(query); @@ -43,7 +46,10 @@ export function useIsLargeScreen(query: string = LARGE_SCREEN_QUERY): boolean { } function matchesQuery(query: string): boolean { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + if ( + typeof window === 'undefined' || + typeof window.matchMedia !== 'function' + ) { return false; } return window.matchMedia(query).matches; diff --git a/packages/web-shell/client/index.html b/packages/web-shell/client/index.html index 30564df443b..75b1110464d 100644 --- a/packages/web-shell/client/index.html +++ b/packages/web-shell/client/index.html @@ -1,4 +1,4 @@ - + @@ -27,7 +27,27 @@ THEME_STORAGE_KEY in main.tsx. -->
diff --git a/packages/web-shell/client/utils/askUserPermission.test.ts b/packages/web-shell/client/utils/askUserPermission.test.ts index fabba1d57b8..eadf1db88de 100644 --- a/packages/web-shell/client/utils/askUserPermission.test.ts +++ b/packages/web-shell/client/utils/askUserPermission.test.ts @@ -23,7 +23,9 @@ describe('isAskUserPermission', () => { it('is true when questions are present and no tool name is given', () => { expect( - isAskUserPermission(req({ rawInput: { questions: [{ question: 'q' }] } })), + isAskUserPermission( + req({ rawInput: { questions: [{ question: 'q' }] } }), + ), ).toBe(true); }); diff --git a/packages/web-shell/client/utils/splitUrl.test.ts b/packages/web-shell/client/utils/splitUrl.test.ts index bad3d832c94..e684caf0d6c 100644 --- a/packages/web-shell/client/utils/splitUrl.test.ts +++ b/packages/web-shell/client/utils/splitUrl.test.ts @@ -25,17 +25,21 @@ describe('buildSplitUrl', () => { }); it('resets the path so no single-session deep-link competes', () => { - expect(new URL(buildSplitUrl(['a'], 'https://host/session/x')).pathname).toBe( - '/', - ); + expect( + new URL(buildSplitUrl(['a'], 'https://host/session/x')).pathname, + ).toBe('/'); }); it('carries the daemon token in the fragment when provided', () => { - const url = new URL(buildSplitUrl(['a', 'b'], 'https://host/', 'secret-tok')); + const url = new URL( + buildSplitUrl(['a', 'b'], 'https://host/', 'secret-tok'), + ); expect(url.searchParams.get('split')).toBe('a,b'); // In the hash, not the query — never sent to the server / logs. expect(url.search).not.toContain('secret-tok'); - expect(new URLSearchParams(url.hash.slice(1)).get('token')).toBe('secret-tok'); + expect(new URLSearchParams(url.hash.slice(1)).get('token')).toBe( + 'secret-tok', + ); }); it('adds no fragment when no token is given', () => { diff --git a/scripts/benchmark-api-latency.mjs b/scripts/benchmark-api-latency.mjs index 3ed38c4c34e..42dca1275ce 100644 --- a/scripts/benchmark-api-latency.mjs +++ b/scripts/benchmark-api-latency.mjs @@ -26,16 +26,25 @@ const require = createRequire(import.meta.url); const { Agent } = require('../packages/core/node_modules/undici/index.js'); const ITERATIONS = parseInt(process.env['ITERATIONS'] ?? '3', 10); -const REQUEST_TIMEOUT_MS = parseInt(process.env['REQUEST_TIMEOUT_MS'] ?? '5000', 10); +const REQUEST_TIMEOUT_MS = parseInt( + process.env['REQUEST_TIMEOUT_MS'] ?? '5000', + 10, +); const DEFAULT_ENDPOINTS = [ - { url: 'https://api.openai.com', label: 'OpenAI' }, - { url: 'https://api.anthropic.com', label: 'Anthropic' }, - { url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', label: 'DashScope (openai-compatible)' }, + { url: 'https://api.openai.com', label: 'OpenAI' }, + { url: 'https://api.anthropic.com', label: 'Anthropic' }, + { + url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + label: 'DashScope (openai-compatible)', + }, ]; const extraUrls = process.env['BENCHMARK_URLS'] - ? process.env['BENCHMARK_URLS'].split(' ').filter(Boolean).map((url) => ({ url, label: url })) + ? process.env['BENCHMARK_URLS'] + .split(' ') + .filter(Boolean) + .map((url) => ({ url, label: url })) : []; const ENDPOINTS = [...DEFAULT_ENDPOINTS, ...extraUrls]; @@ -146,10 +155,10 @@ for (const endpoint of ENDPOINTS) { console.log('\n\n=== Results ===\n'); console.log( 'Endpoint'.padEnd(36) + - 'Cold (avg)'.padStart(12) + - 'Warm (avg)'.padStart(12) + - 'Saved'.padStart(10) + - 'Improvement'.padStart(13), + 'Cold (avg)'.padStart(12) + + 'Warm (avg)'.padStart(12) + + 'Saved'.padStart(10) + + 'Improvement'.padStart(13), ); console.log('─'.repeat(83)); @@ -157,10 +166,10 @@ for (const r of results) { const status = r.pct >= 30 ? '✓' : r.pct >= 10 ? '~' : '✗'; console.log( r.label.slice(0, 35).padEnd(36) + - fmt(r.avgCold).padStart(12) + - fmt(r.avgWarm).padStart(12) + - fmt(r.saved).padStart(10) + - `${r.pct.toFixed(1)}% ${status}`.padStart(13), + fmt(r.avgCold).padStart(12) + + fmt(r.avgWarm).padStart(12) + + fmt(r.saved).padStart(10) + + `${r.pct.toFixed(1)}% ${status}`.padStart(13), ); } diff --git a/scripts/measure-flicker.mjs b/scripts/measure-flicker.mjs index bff6722df6f..dc053b9cd9b 100755 --- a/scripts/measure-flicker.mjs +++ b/scripts/measure-flicker.mjs @@ -110,7 +110,9 @@ function renderDelta(current, baseline) { const b = baseline.counts[p.name]; const d = c - b; const arrow = d < 0 ? '↓' : d > 0 ? '↑' : '·'; - stdout.write(` ${p.name.padEnd(18)} ${d > 0 ? '+' : ''}${d} ${arrow}\n`); + stdout.write( + ` ${p.name.padEnd(18)} ${d > 0 ? '+' : ''}${d} ${arrow}\n`, + ); } stdout.write( '\ntip: lower clearTerminalPair (and lower clearScreen) on "current" wins.\n',