Compare commits

..

7 Commits

Author SHA1 Message Date
yiliang114
d9328fa478 feat: 统一LSP工具并扩展操作支持
- 创建统一的LSP工具,整合了之前的多个分散LSP工具
- 增加对更多LSP操作的支持,包括hover、documentSymbol、goToImplementation等
- 扩展LSP类型定义,支持Call Hierarchy等高级功能
- 更新配置和测试文件以适配新的LSP工具架构
- 保持向后兼容性,同时引入新工具名称映射

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

此更改是LSP工具重构计划的一部分,旨在提供更统一和功能完备的LSP集成体验。
2026-01-18 19:34:17 +08:00
yiliang114
a14d1e27bb Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/support-lsp 2026-01-18 13:49:32 +08:00
yiliang114
c4e6c096dc feat(cli): improve LSP service implementation with type safety and iteration fixes
- Fix iteration over Map and Set collections by using Array.from() to avoid
  potential modification during iteration issues
- Add proper type casting for test mocks to ensure type safety
- Add null checks and type guards for LSP reference and symbol processing
- Improve type annotations for LSP server status and configuration objects
- Update path validation to use workspace root instead of config.cwd

These changes improve the robustness and type safety of the LSP service implementation.
2026-01-07 19:59:19 +08:00
yiliang114
4857f2f803 Merge branch 'feat/support-lsp-1' into feat/support-lsp 2026-01-07 15:22:34 +08:00
yiliang114
5a907c3415 wip(cli): support lsp 2026-01-07 15:21:33 +08:00
yiliang114
d1d215b82e wip(cli): support lsp 2026-01-05 10:18:24 +08:00
yiliang114
a67a8d0277 wip(cli): support lsp 2026-01-05 01:42:05 +08:00
62 changed files with 7050 additions and 8040 deletions

View File

@@ -1,267 +0,0 @@
name: 'VSCode Extension Tests'
on:
push:
branches:
- 'main'
- 'release/**'
- feat/ide-test-ci
paths:
- 'packages/vscode-ide-companion/**'
- '.github/workflows/vscode-extension-test.yml'
pull_request:
branches:
- 'main'
- 'release/**'
paths:
- 'packages/vscode-ide-companion/**'
- '.github/workflows/vscode-extension-test.yml'
workflow_dispatch:
concurrency:
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
cancel-in-progress: true
permissions:
contents: 'read'
checks: 'write'
pull-requests: 'write' # Needed to comment on PRs
jobs:
unit-test:
name: 'Unit Tests'
runs-on: '${{ matrix.os }}'
strategy:
fail-fast: false
matrix:
os:
- 'ubuntu-latest'
node-version:
- '20.x'
steps:
- name: 'Checkout'
uses: 'actions/checkout@v4'
- name: 'Setup Node.js'
uses: 'actions/setup-node@v4'
with:
node-version: '${{ matrix.node-version }}'
cache: 'npm'
- name: 'Install dependencies'
run: 'npm ci'
- name: 'Build project'
run: 'npm run build'
working-directory: 'packages/vscode-ide-companion'
- name: 'Run unit tests'
run: 'npm run test:ci'
working-directory: 'packages/vscode-ide-companion'
- name: 'Upload coverage'
if: matrix.os == 'ubuntu-latest' && matrix.node-version == '20.x'
uses: 'actions/upload-artifact@v4'
with:
name: 'coverage-unit-test'
path: 'packages/vscode-ide-companion/coverage'
integration-test:
name: 'Integration Tests'
runs-on: 'ubuntu-latest'
needs: 'unit-test'
if: needs.unit-test.result == 'success'
steps:
- name: 'Checkout'
uses: 'actions/checkout@v4'
- name: 'Setup Node.js'
uses: 'actions/setup-node@v4'
with:
node-version: '20.x'
cache: 'npm'
- name: 'Install dependencies'
run: 'npm ci'
- name: 'Build project'
run: 'npm run build'
working-directory: 'packages/vscode-ide-companion'
- name: 'Run integration tests'
run: 'xvfb-run -a npm run test:integration'
working-directory: 'packages/vscode-ide-companion'
e2e-test:
name: 'E2E Tests'
runs-on: 'ubuntu-latest'
needs: 'integration-test'
if: needs.integration-test.result == 'success'
steps:
- name: 'Checkout'
uses: 'actions/checkout@v4'
- name: 'Setup Node.js'
uses: 'actions/setup-node@v4'
with:
node-version: '20.x'
cache: 'npm'
- name: 'Install dependencies'
run: 'npm ci'
- name: 'Install Playwright browsers'
run: 'npx playwright install --with-deps chromium'
working-directory: 'packages/vscode-ide-companion'
- name: 'Build project'
run: 'npm run build'
working-directory: 'packages/vscode-ide-companion'
- name: 'Run E2E tests'
run: 'xvfb-run -a npm run test:e2e'
working-directory: 'packages/vscode-ide-companion'
- name: 'Upload E2E test results'
if: always()
uses: 'actions/upload-artifact@v4'
with:
name: 'e2e-test-results'
path: 'packages/vscode-ide-companion/e2e/test-results'
- name: 'Upload Playwright report'
if: always()
uses: 'actions/upload-artifact@v4'
with:
name: 'playwright-report'
path: 'packages/vscode-ide-companion/e2e/playwright-report'
e2e-vscode-test:
name: 'VSCode E2E Tests'
runs-on: 'ubuntu-latest'
needs: 'e2e-test'
if: needs.e2e-test.result == 'success'
steps:
- name: 'Checkout'
uses: 'actions/checkout@v4'
- name: 'Setup Node.js'
uses: 'actions/setup-node@v4'
with:
node-version: '20.x'
cache: 'npm'
- name: 'Install dependencies'
run: 'npm ci'
- name: 'Install Playwright browsers'
run: 'npx playwright install --with-deps'
working-directory: 'packages/vscode-ide-companion'
- name: 'Build project'
run: 'npm run build'
working-directory: 'packages/vscode-ide-companion'
- name: 'Run VSCode E2E tests'
run: 'xvfb-run -a npm run test:e2e:vscode'
working-directory: 'packages/vscode-ide-companion'
- name: 'Upload VSCode E2E test results'
if: always()
uses: 'actions/upload-artifact@v4'
with:
name: 'vscode-e2e-test-results'
path: 'packages/vscode-ide-companion/e2e-vscode/test-results'
- name: 'Upload VSCode Playwright report'
if: always()
uses: 'actions/upload-artifact@v4'
with:
name: 'vscode-playwright-report'
path: 'packages/vscode-ide-companion/e2e-vscode/playwright-report'
# Job to comment test results on PR if tests fail
comment-on-pr:
name: 'Comment PR with Test Results'
runs-on: 'ubuntu-latest'
needs: [unit-test, integration-test, e2e-test, e2e-vscode-test]
if: always() && github.event_name == 'pull_request' && (needs.unit-test.result == 'failure' || needs.integration-test.result == 'failure' || needs.e2e-test.result == 'failure' || needs.e2e-vscode-test.result == 'failure')
steps:
- name: 'Checkout'
uses: 'actions/checkout@v4'
- name: 'Find Comment'
uses: 'peter-evans/find-comment@v3'
id: 'find-comment'
with:
issue-number: '${{ github.event.pull_request.number }}'
comment-author: 'github-actions[bot]'
body-includes: 'VSCode Extension Test Results'
- name: 'Comment on PR'
uses: 'peter-evans/create-or-update-comment@v4'
with:
comment-id: '${{ steps.find-comment.outputs.comment-id }}'
issue-number: '${{ github.event.pull_request.number }}'
edit-mode: 'replace'
body: |
## VSCode Extension Test Results
Tests have failed for this pull request. Please check the following jobs:
- Unit Tests: `${{ needs.unit-test.result }}`
- Integration Tests: `${{ needs.integration-test.result }}`
- E2E Tests: `${{ needs.e2e-test.result }}`
- VSCode E2E Tests: `${{ needs.e2e-vscode-test.result }}`
[Check the workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.
# Job to create an issue if tests fail when not on a PR (e.g. direct push to main)
create-issue:
name: 'Create Issue for Failed Tests'
runs-on: 'ubuntu-latest'
needs: [unit-test, integration-test, e2e-test, e2e-vscode-test]
if: always() && github.event_name == 'push' && (needs.unit-test.result == 'failure' || needs.integration-test.result == 'failure' || needs.e2e-test.result == 'failure' || needs.e2e-vscode-test.result == 'failure')
steps:
- name: 'Checkout'
uses: 'actions/checkout@v4'
- name: 'Create Issue'
uses: 'actions/github-script@v7'
with:
script: |
const { owner, repo } = context.repo;
const result = await github.rest.issues.create({
owner,
repo,
title: `VSCode Extension Tests Failed - ${context.sha.substring(0, 7)}`,
body: `VSCode Extension Tests failed on commit ${context.sha}\n\nResults:\n- Unit Tests: ${{ needs.unit-test.result }}\n- Integration Tests: ${{ needs.integration-test.result }}\n- E2E Tests: ${{ needs.e2e-test.result }}\n- VSCode E2E Tests: ${{ needs.e2e-vscode-test.result }}\n\nWorkflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`
});
# Summary job to pass/fail the entire workflow based on test results
vscode-extension-tests:
name: 'VSCode Extension Tests Summary'
runs-on: 'ubuntu-latest'
needs:
- 'unit-test'
- 'integration-test'
- 'e2e-test'
- 'e2e-vscode-test'
if: always()
steps:
- name: 'Check test results'
run: |
if [[ "${{ needs.unit-test.result }}" == "failure" ]] || \
[[ "${{ needs.integration-test.result }}" == "failure" ]] || \
[[ "${{ needs.e2e-test.result }}" == "failure" ]] || \
[[ "${{ needs.e2e-vscode-test.result }}" == "failure" ]]; then
echo "One or more test jobs failed"
exit 1
fi
echo "All tests passed!"

5
.gitignore vendored
View File

@@ -63,8 +63,3 @@ patch_output.log
docs-site/.next
# content is a symlink to ../docs
docs-site/content
# vscode-ida-companion test files
.vscode-test/
test-results/
e2e-vscode/

View File

@@ -13,5 +13,10 @@
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"vitest.disableWorkspaceWarning": true
"vitest.disableWorkspaceWarning": true,
"lsp": {
"enabled": true,
"allowed": ["typescript-language-server"],
"excluded": ["gopls"]
}
}

147
cclsp-integration-plan.md Normal file
View File

@@ -0,0 +1,147 @@
# Qwen Code CLI LSP 集成实现方案分析
## 1. 项目概述
本方案旨在将 LSPLanguage Server Protocol能力原生集成到 Qwen Code CLI 中,使 AI 代理能够利用代码导航、定义查找、引用查找等功能。LSP 将作为与 MCP 并行的一级扩展机制实现。
## 2. 技术方案对比
### 2.1 Piebald-AI/claude-code-lsps 方案
- **架构**: 客户端直接与每个 LSP 通信,通过 `.lsp.json` 配置文件声明服务器命令/参数、stdio 传输和文件扩展名路由
- **用户配置**: 低摩擦,只需放置 `.lsp.json` 配置并确保 LSP 二进制文件已安装
- **安全**: LSP 子进程以用户权限运行,无内置信任门控
- **功能覆盖**: 可以暴露完整的 LSP 表面hover、诊断、代码操作、重命名等
### 2.2 原生 LSP 客户端方案(推荐方案)
- **架构**: Qwen Code CLI 直接作为 LSP 客户端,与语言服务器建立 JSON-RPC 连接
- **用户配置**: 支持内置预设 + 用户自定义 `.lsp.json` 配置
- **安全**: 与 MCP 共享相同的安全控制(信任工作区、允许/拒绝列表、确认提示)
- **功能覆盖**: 暴露完整的 LSP 功能(流式诊断、代码操作、重命名、语义标记等)
### 2.3 cclsp + MCP 方案(备选)
- **架构**: 通过 MCP 协议调用 cclsp 作为 LSP 桥接
- **用户配置**: 需要 MCP 配置
- **安全**: 通过 MCP 安全控制
- **功能覆盖**: 依赖于 cclsp 映射的 MCP 工具
## 3. 原生 LSP 集成详细计划
### 3.1 方案选择
- **推荐方案**: 原生 LSP 客户端作为主要路径,因为它提供完整 LSP 功能、更低延迟和更好的用户体验
- **兼容层**: 保留 cclsp+MCP 作为现有 MCP 工作流的兼容桥接
- **并行架构**: LSP 和 MCP 作为独立的扩展机制共存,共享安全策略
### 3.2 实现步骤
#### 3.2.1 创建原生 LSP 服务
`packages/cli/src/services/lsp/` 目录下创建 `NativeLspService` 类,处理:
- 工作区语言检测
- 自动发现和启动语言服务器
- 与现有文档/编辑模型同步
- LSP 能力直接暴露给代理
#### 3.2.2 配置支持
- 支持内置预设配置(常见语言服务器)
- 支持用户自定义 `.lsp.json` 配置文件
- 与 MCP 配置共存,共享信任控制
#### 3.2.3 集成启动流程
-`packages/cli/src/config/config.ts` 中的 `loadCliConfig` 函数内集成
- 确保 LSP 服务与 MCP 服务共享相同的安全控制机制
- 处理沙箱预检和主运行的重复调用问题
#### 3.2.4 功能标志配置
-`packages/cli/src/config/settingsSchema.ts` 中添加新的设置项
- 提供全局开关(如 `lsp.enabled=false`)允许用户禁用 LSP 功能
- 尊重 `mcp.allowed`/`mcp.excluded` 和文件夹信任设置
#### 3.2.5 安全控制
- 与 MCP 共享相同的安全控制机制
- 在信任工作区中自动启用,在非信任工作区中提示用户
- 实现路径允许列表和进程启动确认
#### 3.2.6 错误处理与用户通知
- 检测缺失的语言服务器并提供安装命令
- 通过现有 MCP 状态 UI 显示错误信息
- 实现重试/退避机制,检测沙箱环境并抑制自动启动
### 3.3 需要确认的不确定项
1. **启动集成点**:在 `loadCliConfig` 中集成原生 LSP 服务,需确保与 MCP 服务的协调
2. **配置优先级**:如果用户已有 cclsp MCP 配置,应保持并存还是优先使用原生 LSP
3. **功能开关设计**开关应该是全局级别的LSP 和 MCP 可独立启用/禁用
4. **共享安全模型**:如何在代码中复用 MCP 的信任/安全控制逻辑
5. **语言服务器管理**:如何管理 LSP 服务器生命周期并与文档编辑模型同步
6. **依赖检测机制**:检测 LSP 服务器可用性,失败时提供降级选项
7. **测试策略**:需要测试 LSP 与 MCP 的并行运行,以及共享安全控制
### 3.4 安全考虑
- 与 MCP 共享相同的安全控制模型
- 仅在受信任工作区中启用自动 LSP 功能
- 提供用户确认机制用于启动新的 LSP 服务器
- 防止路径劫持,使用安全的路径解析
### 3.5 高级 LSP 功能支持
- **完整 LSP 功能**: 支持流式诊断、代码操作、重命名、语义高亮、工作区编辑等
- **兼容 Claude 配置**: 支持导入 Claude Code 风格的 `.lsp.json` 配置
- **性能优化**: 优化 LSP 服务器启动时间和内存使用
### 3.6 用户体验
- 提供安装提示而非自动安装
- 在统一的状态界面显示 LSP 和 MCP 服务器状态
- 提供独立开关让用户控制 LSP 和 MCP 功能
- 为只读/沙箱环境提供安全的配置处理和清晰的错误消息
## 4. 实施总结
### 4.1 已完成的工作
1. **NativeLspService 类**创建了核心服务类包含语言检测、配置合并、LSP 连接管理等功能
2. **LSP 连接工厂**:实现了基于 stdio 的 LSP 连接创建和管理
3. **语言检测机制**:实现了基于文件扩展名和项目配置文件的语言自动检测
4. **配置系统**:实现了内置预设、用户配置和 Claude 兼容配置的合并
5. **安全控制**:实现了与 MCP 共享的安全控制机制,包括信任检查、用户确认、路径安全验证
6. **CLI 集成**:在 `loadCliConfig` 函数中集成了 LSP 服务初始化点
### 4.2 关键组件
#### 4.2.1 LspConnectionFactory
- 使用 `vscode-jsonrpc``vscode-languageserver-protocol` 实现 LSP 连接
- 支持 stdio 传输方式,可以扩展支持 TCP 传输
- 提供连接创建、初始化和关闭的完整生命周期管理
#### 4.2.2 NativeLspService
- **语言检测**:扫描项目文件和配置文件来识别编程语言
- **配置合并**:按优先级合并内置预设、用户配置和兼容层配置
- **LSP 服务器管理**:启动、停止和状态管理
- **安全控制**:与 MCP 共享的信任和确认机制
#### 4.2.3 配置架构
- **内置预设**:为常见语言提供默认 LSP 服务器配置
- **用户配置**:支持 `.lsp.json` 文件格式
- **Claude 兼容**:可导入 Claude Code 的 LSP 配置
### 4.3 依赖管理
- 使用 `vscode-languageserver-protocol` 进行 LSP 协议通信
- 使用 `vscode-jsonrpc` 进行 JSON-RPC 消息传递
- 使用 `vscode-languageserver-textdocument` 管理文档版本
### 4.4 安全特性
- 工作区信任检查
- 用户确认机制(对于非信任工作区)
- 命令存在性验证
- 路径安全性检查
## 5. 总结
原生 LSP 客户端是当前最符合 Qwen Code 架构的选择,它提供了完整的 LSP 功能、更低的延迟和更好的用户体验。LSP 作为与 MCP 并行的一级扩展机制,将与 MCP 共享安全控制策略但提供更丰富的代码智能功能。cclsp+MCP 可作为兼容层保留,以支持现有的 MCP 工作流。
该实现方案将使 Qwen Code CLI 具备完整的 LSP 功能,包括代码跳转、引用查找、自动补全、代码诊断等,为 AI 代理提供更丰富的代码理解能力。

View File

@@ -275,7 +275,7 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
| `tools.truncateToolOutputThreshold` | number | Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `25000` | Requires restart: Yes |
| `tools.truncateToolOutputLines` | number | Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `1000` | Requires restart: Yes |
| `tools.autoAccept` | boolean | Controls whether the CLI automatically accepts and executes tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation. If set to `true`, the CLI will bypass the confirmation prompt for tools deemed safe. | `false` | |
| `tools.experimental.skills` | boolean | Enable experimental Agent Skills feature | `false` | |
| `tools.experimental.skills` | boolean | Enable experimental Agent Skills feature | `false` | |
#### mcp

485
package-lock.json generated
View File

@@ -39,6 +39,7 @@
"globals": "^16.0.0",
"husky": "^9.1.7",
"json": "^11.0.0",
"json-schema": "^0.4.0",
"lint-staged": "^16.1.6",
"memfs": "^4.42.0",
"mnemonist": "^0.40.3",
@@ -67,13 +68,6 @@
"node-pty": "^1.0.0"
}
},
"node_modules/@adobe/css-tools": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
"integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
"dev": true,
"license": "MIT"
},
"node_modules/@alcalzone/ansi-tokenize": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.0.tgz",
@@ -2805,22 +2799,6 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@pnpm/config.env-replace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",
@@ -3498,61 +3476,6 @@
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"license": "MIT"
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@adobe/css-tools": "^4.4.0",
"aria-query": "^5.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.6.3",
"picocolors": "^1.1.1",
"redent": "^3.0.0"
},
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
"dev": true,
"license": "MIT"
},
"node_modules/@testing-library/react": {
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz",
"integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@testing-library/dom": "^10.0.0",
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@textlint/ast-node-types": {
"version": "15.2.2",
"resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.2.2.tgz",
@@ -4607,23 +4530,6 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vscode/test-electron": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz",
"integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==",
"dev": true,
"license": "MIT",
"dependencies": {
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.5",
"jszip": "^3.10.1",
"ora": "^8.1.0",
"semver": "^7.6.2"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@vscode/vsce": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.6.0.tgz",
@@ -6923,13 +6829,6 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true,
"license": "MIT"
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -9689,13 +9588,6 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"dev": true,
"license": "MIT"
},
"node_modules/immutable": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
@@ -10388,19 +10280,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-interactive": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
"integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-map": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
@@ -10610,19 +10489,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-unicode-supported": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
"integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-weakmap": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -10939,6 +10805,13 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"dev": true,
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -11072,59 +10945,6 @@
"node": ">=4.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"dev": true,
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -11282,16 +11102,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -11576,49 +11386,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/log-symbols": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz",
"integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"is-unicode-supported": "^1.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-symbols/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/log-symbols/node_modules/is-unicode-supported": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz",
"integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-update": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
@@ -11964,16 +11731,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -12936,117 +12693,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/ora": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz",
"integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"cli-cursor": "^5.0.0",
"cli-spinners": "^2.9.2",
"is-interactive": "^2.0.0",
"is-unicode-supported": "^2.0.0",
"log-symbols": "^6.0.0",
"stdin-discarder": "^0.2.2",
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ora/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/ora/node_modules/cli-cursor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
"integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"restore-cursor": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ora/node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/ora/node_modules/onetime": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-function": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ora/node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"onetime": "^7.0.0",
"signal-exit": "^4.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ora/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/outvariant": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
@@ -13141,13 +12787,6 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"dev": true,
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -13470,53 +13109,6 @@
"pathe": "^2.0.1"
}
},
"node_modules/playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
@@ -14304,30 +13896,6 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"indent-string": "^4.0.0",
"strip-indent": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/redent/node_modules/indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -14993,13 +14561,6 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"dev": true,
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -15343,19 +14904,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/stdin-discarder": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
"integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -15627,19 +15175,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"min-indent": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -21906,9 +21441,6 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/markdown-it": "^14.1.2",
@@ -21919,7 +21451,6 @@
"@types/vscode": "^1.85.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^3.6.0",
"autoprefixer": "^10.4.22",
"esbuild": "^0.25.3",

View File

@@ -94,6 +94,7 @@
"globals": "^16.0.0",
"husky": "^9.1.7",
"json": "^11.0.0",
"json-schema": "^0.4.0",
"lint-staged": "^16.1.6",
"memfs": "^4.42.0",
"mnemonist": "^0.40.3",

View File

@@ -0,0 +1,140 @@
# LSP 调试指南
本指南介绍如何调试 packages/cli 中的 LSP (Language Server Protocol) 功能。
## 1. 启用调试模式
CLI 支持调试模式,可以提供额外的日志信息:
```bash
# 使用 debug 标志运行
qwen --debug [你的命令]
# 或设置环境变量
DEBUG=true qwen [你的命令]
DEBUG_MODE=true qwen [你的命令]
```
## 2. LSP 配置选项
LSP 功能通过设置系统配置,包含以下选项:
- `lsp.enabled`: 启用/禁用原生 LSP 客户端(默认为 `false`
- `lsp.allowed`: 允许的 LSP 服务器名称白名单
- `lsp.excluded`: 排除的 LSP 服务器名称黑名单
在 settings.json 中的示例配置:
```json
{
"lsp": {
"enabled": true,
"allowed": ["typescript-language-server", "pylsp"],
"excluded": ["gopls"]
}
}
```
也可以在 `settings.json` 中配置 `lsp.languageServers`,格式与 `.lsp.json` 一致。
## 3. NativeLspService 调试功能
`NativeLspService` 类包含几个调试功能:
### 3.1 控制台日志
服务向控制台输出状态消息:
- `LSP 服务器 ${name} 启动成功` - 服务器成功启动
- `LSP 服务器 ${name} 启动失败` - 服务器启动失败
- `工作区不受信任,跳过 LSP 服务器发现` - 工作区不受信任,跳过发现
### 3.2 错误处理
服务具有全面的错误处理和详细的错误消息
### 3.3 状态跟踪
您可以通过 `getStatus()` 方法检查所有 LSP 服务器的状态
## 4. 调试命令
```bash
# 启用调试运行
qwen --debug --prompt "调试 LSP 功能"
# 检查在您的项目中检测到哪些 LSP 服务器
# 系统会自动检测语言和相应的 LSP 服务器
```
## 5. 手动 LSP 服务器配置
您还可以在项目根目录使用 `.lsp.json` 文件手动配置 LSP 服务器。
推荐使用新格式(以服务器名称为键),旧格式仍然兼容但会提示迁移:
```json
{
"languageServers": {
"pylsp": {
"command": "pylsp",
"args": [],
"languages": ["python"],
"transport": "stdio",
"settings": {},
"workspaceFolder": null,
"startupTimeout": 10000,
"shutdownTimeout": 3000,
"restartOnCrash": true,
"maxRestarts": 3,
"trustRequired": true
}
}
}
```
旧格式示例:
```json
{
"python": {
"command": "pylsp",
"args": [],
"transport": "stdio",
"trustRequired": true
}
}
```
## 6. LSP 问题排查
### 6.1 检查 LSP 服务器是否已安装
- 对于 TypeScript/JavaScript: `typescript-language-server`
- 对于 Python: `pylsp`
- 对于 Go: `gopls`
### 6.2 验证工作区信任
- LSP 服务器可能需要受信任的工作区才能启动
- 检查 `security.folderTrust.enabled` 设置
### 6.3 查看日志
- 查找以 `LSP 服务器` 开头的控制台消息
- 检查命令存在性和路径安全性问题
## 7. LSP 服务启动流程
LSP 服务的启动遵循以下流程:
1. **发现和准备**: `discoverAndPrepare()` 方法检测工作区中的编程语言
2. **创建服务器句柄**: 根据检测到的语言创建对应的服务器句柄
3. **启动服务器**: `start()` 方法启动所有服务器句柄
4. **状态管理**: 服务器状态在 `NOT_STARTED`, `IN_PROGRESS`, `READY`, `FAILED` 之间转换
## 8. 调试技巧
- 使用 `--debug` 标志查看详细的启动过程
- 检查工作区是否受信任(影响 LSP 服务器启动)
- 确认 LSP 服务器命令在系统 PATH 中可用
- 使用 `getStatus()` 方法监控服务器运行状态

View File

@@ -20,6 +20,25 @@ import { ExtensionStorage, type Extension } from './extension.js';
import * as ServerConfig from '@qwen-code/qwen-code-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { NativeLspService } from '../services/lsp/NativeLspService.js';
const createNativeLspServiceInstance = () => ({
discoverAndPrepare: vi.fn(),
start: vi.fn(),
definitions: vi.fn().mockResolvedValue([]),
references: vi.fn().mockResolvedValue([]),
workspaceSymbols: vi.fn().mockResolvedValue([]),
});
vi.mock('../services/lsp/NativeLspService.js', () => ({
NativeLspService: vi.fn().mockImplementation(() => ({
discoverAndPrepare: vi.fn(),
start: vi.fn(),
definitions: vi.fn().mockResolvedValue([]),
references: vi.fn().mockResolvedValue([]),
workspaceSymbols: vi.fn().mockResolvedValue([]),
})),
}));
vi.mock('./trustedFolders.js', () => ({
isWorkspaceTrusted: vi
@@ -27,6 +46,17 @@ vi.mock('./trustedFolders.js', () => ({
.mockReturnValue({ isTrusted: true, source: 'file' }), // Default to trusted
}));
const nativeLspServiceMock = vi.mocked(NativeLspService);
const getLastLspInstance = () => {
const results = nativeLspServiceMock.mock.results;
if (results.length === 0) {
return undefined;
}
return results[results.length - 1]?.value as ReturnType<
typeof createNativeLspServiceInstance
>;
};
vi.mock('fs', async (importOriginal) => {
const actualFs = await importOriginal<typeof import('fs')>();
const pathMod = await import('node:path');
@@ -516,6 +546,10 @@ describe('loadCliConfig', () => {
beforeEach(() => {
vi.resetAllMocks();
nativeLspServiceMock.mockReset();
nativeLspServiceMock.mockImplementation(() =>
createNativeLspServiceInstance(),
);
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
});
@@ -585,6 +619,63 @@ describe('loadCliConfig', () => {
expect(config.getShowMemoryUsage()).toBe(false);
});
it('should initialize native LSP service when enabled', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
lsp: {
enabled: true,
allowed: ['typescript-language-server'],
excluded: ['pylsp'],
},
};
const config = await loadCliConfig(
settings,
[],
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
expect(config.isLspEnabled()).toBe(true);
expect(config.getLspAllowed()).toEqual(['typescript-language-server']);
expect(config.getLspExcluded()).toEqual(['pylsp']);
expect(nativeLspServiceMock).toHaveBeenCalledTimes(1);
const lspInstance = getLastLspInstance();
expect(lspInstance).toBeDefined();
expect(lspInstance?.discoverAndPrepare).toHaveBeenCalledTimes(1);
expect(lspInstance?.start).toHaveBeenCalledTimes(1);
const options = nativeLspServiceMock.mock.calls[0][5];
expect(options?.allowedServers).toEqual(['typescript-language-server']);
expect(options?.excludedServers).toEqual(['pylsp']);
});
it('should skip native LSP startup when startLsp option is false', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { lsp: { enabled: true } };
const config = await loadCliConfig(
settings,
[],
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
undefined,
{ startLsp: false },
);
expect(config.isLspEnabled()).toBe(true);
expect(nativeLspServiceMock).not.toHaveBeenCalled();
expect(getLastLspInstance()).toBeUndefined();
});
it('should set showMemoryUsage to false by default from settings if CLI flag is not present', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);

View File

@@ -21,9 +21,11 @@ import {
OutputFormat,
isToolEnabled,
SessionService,
ideContextStore,
type ResumedSessionData,
type FileFilteringOptions,
type MCPServerConfig,
type LspClient,
type ToolName,
EditTool,
ShellTool,
@@ -48,6 +50,7 @@ import { annotateActiveExtensions } from './extension.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { appEvents } from '../utils/events.js';
import { mcpCommand } from '../commands/mcp.js';
import { NativeLspService } from '../services/lsp/NativeLspService.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
@@ -154,6 +157,105 @@ export interface CliArgs {
channel: string | undefined;
}
export interface LoadCliConfigOptions {
/**
* Whether to start the native LSP service during config load.
* Disable when doing preflight runs (e.g., sandbox preparation).
*/
startLsp?: boolean;
}
class NativeLspClient implements LspClient {
constructor(private readonly service: NativeLspService) {}
workspaceSymbols(query: string, limit?: number) {
return this.service.workspaceSymbols(query, limit);
}
definitions(
location: Parameters<NativeLspService['definitions']>[0],
serverName?: string,
limit?: number,
) {
return this.service.definitions(location, serverName, limit);
}
references(
location: Parameters<NativeLspService['references']>[0],
serverName?: string,
includeDeclaration?: boolean,
limit?: number,
) {
return this.service.references(
location,
serverName,
includeDeclaration,
limit,
);
}
/**
* Get hover information (documentation, type info) for a symbol.
*/
hover(
location: Parameters<NativeLspService['hover']>[0],
serverName?: string,
) {
return this.service.hover(location, serverName);
}
/**
* Get all symbols in a document.
*/
documentSymbols(uri: string, serverName?: string, limit?: number) {
return this.service.documentSymbols(uri, serverName, limit);
}
/**
* Find implementations of an interface or abstract method.
*/
implementations(
location: Parameters<NativeLspService['implementations']>[0],
serverName?: string,
limit?: number,
) {
return this.service.implementations(location, serverName, limit);
}
/**
* Prepare call hierarchy item at a position (functions/methods).
*/
prepareCallHierarchy(
location: Parameters<NativeLspService['prepareCallHierarchy']>[0],
serverName?: string,
limit?: number,
) {
return this.service.prepareCallHierarchy(location, serverName, limit);
}
/**
* Find all functions/methods that call the given function.
*/
incomingCalls(
item: Parameters<NativeLspService['incomingCalls']>[0],
serverName?: string,
limit?: number,
) {
return this.service.incomingCalls(item, serverName, limit);
}
/**
* Find all functions/methods called by the given function.
*/
outgoingCalls(
item: Parameters<NativeLspService['outgoingCalls']>[0],
serverName?: string,
limit?: number,
) {
return this.service.outgoingCalls(item, serverName, limit);
}
}
function normalizeOutputFormat(
format: string | OutputFormat | undefined,
): OutputFormat | undefined {
@@ -689,6 +791,7 @@ export async function loadCliConfig(
extensionEnablementManager: ExtensionEnablementManager,
argv: CliArgs,
cwd: string = process.cwd(),
options: LoadCliConfigOptions = {},
): Promise<Config> {
const debugMode = isDebugMode(argv);
@@ -765,6 +868,13 @@ export async function loadCliConfig(
);
let mcpServers = mergeMcpServers(settings, activeExtensions);
// LSP configuration derived from settings; defaults to disabled for safety.
const lspEnabled = settings.lsp?.enabled ?? false;
const lspAllowed = settings.lsp?.allowed ?? settings.mcp?.allowed;
const lspExcluded = settings.lsp?.excluded ?? settings.mcp?.excluded;
const lspLanguageServers = settings.lsp?.languageServers;
let lspClient: LspClient | undefined;
const question = argv.promptInteractive || argv.prompt || '';
const inputFormat: InputFormat =
(argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT;
@@ -991,7 +1101,7 @@ export async function loadCliConfig(
const modelProvidersConfig = settings.modelProviders;
return new Config({
const config = new Config({
sessionId,
sessionData,
embeddingModel: DEFAULT_QWEN_EMBEDDING_MODEL,
@@ -1081,7 +1191,40 @@ export async function loadCliConfig(
// always be true and the settings file can never disable recording.
chatRecording:
argv.chatRecording ?? settings.general?.chatRecording ?? true,
lsp: {
enabled: lspEnabled,
allowed: lspAllowed,
excluded: lspExcluded,
},
});
const shouldStartLsp = options.startLsp ?? true;
if (shouldStartLsp && lspEnabled) {
try {
const lspService = new NativeLspService(
config,
config.getWorkspaceContext(),
appEvents,
fileService,
ideContextStore,
{
allowedServers: lspAllowed,
excludedServers: lspExcluded,
requireTrustedWorkspace: folderTrust,
inlineServerConfigs: lspLanguageServers,
},
);
await lspService.discoverAndPrepare();
await lspService.start();
lspClient = new NativeLspClient(lspService);
config.setLspClient(lspClient);
} catch (err) {
logger.warn('Failed to initialize native LSP service:', err);
}
}
return config;
}
function allowedMcpServers(

View File

@@ -0,0 +1,38 @@
import type { JSONSchema7 } from 'json-schema';
export const lspSettingsSchema: JSONSchema7 = {
type: 'object',
properties: {
'lsp.enabled': {
type: 'boolean',
default: true,
description: '启用 LSP 语言服务器协议支持'
},
'lsp.allowed': {
type: 'array',
items: {
type: 'string'
},
default: [],
description: '允许运行的 LSP 服务器列表'
},
'lsp.excluded': {
type: 'array',
items: {
type: 'string'
},
default: [],
description: '禁止运行的 LSP 服务器列表'
},
'lsp.autoDetect': {
type: 'boolean',
default: true,
description: '自动检测项目语言并启动相应 LSP 服务器'
},
'lsp.serverTimeout': {
type: 'number',
default: 10000,
description: 'LSP 服务器启动超时时间(毫秒)'
}
}
};

View File

@@ -160,6 +160,39 @@ export function getSystemDefaultsPath(): string {
);
}
function getVsCodeSettingsPath(workspaceDir: string): string {
return path.join(workspaceDir, '.vscode', 'settings.json');
}
function loadVsCodeSettings(workspaceDir: string): Settings {
const vscodeSettingsPath = getVsCodeSettingsPath(workspaceDir);
try {
if (fs.existsSync(vscodeSettingsPath)) {
const content = fs.readFileSync(vscodeSettingsPath, 'utf-8');
const rawSettings: unknown = JSON.parse(stripJsonComments(content));
if (
typeof rawSettings !== 'object' ||
rawSettings === null ||
Array.isArray(rawSettings)
) {
console.error(
`VS Code settings file is not a valid JSON object: ${vscodeSettingsPath}`,
);
return {};
}
return rawSettings as Settings;
}
} catch (error: unknown) {
console.error(
`Error loading VS Code settings from ${vscodeSettingsPath}:`,
getErrorMessage(error),
);
}
return {};
}
export type { DnsResolutionOrder } from './settingsSchema.js';
export enum SettingScope {
@@ -723,6 +756,9 @@ export function loadSettings(
workspaceDir,
).getWorkspaceSettingsPath();
// Load VS Code settings as an additional source of configuration
const vscodeSettings = loadVsCodeSettings(workspaceDir);
const loadAndMigrate = (
filePath: string,
scope: SettingScope,
@@ -827,6 +863,14 @@ export function loadSettings(
userSettings = resolveEnvVarsInObject(userResult.settings);
workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings);
// Merge VS Code settings into workspace settings (VS Code settings take precedence)
workspaceSettings = customDeepMerge(
getMergeStrategyForPath,
{},
workspaceSettings,
vscodeSettings,
) as Settings;
// Support legacy theme names
if (userSettings.ui?.theme === 'VS') {
userSettings.ui.theme = DefaultLight.name;
@@ -840,11 +884,13 @@ export function loadSettings(
}
// For the initial trust check, we can only use user and system settings.
// We also include VS Code settings as they may contain trust-related settings
const initialTrustCheckSettings = customDeepMerge(
getMergeStrategyForPath,
{},
systemSettings,
userSettings,
vscodeSettings, // Include VS Code settings
);
const isTrusted =
isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true;
@@ -858,9 +904,18 @@ export function loadSettings(
isTrusted,
);
// Add VS Code settings to the temp merged settings for environment loading
// Since loadEnvironment depends on settings, we need to consider VS Code settings as well
const tempMergedSettingsWithVsCode = customDeepMerge(
getMergeStrategyForPath,
{},
tempMergedSettings,
vscodeSettings,
) as Settings;
// loadEnviroment depends on settings so we have to create a temp version of
// the settings to avoid a cycle
loadEnvironment(tempMergedSettings);
loadEnvironment(tempMergedSettingsWithVsCode);
// Create LoadedSettings first

View File

@@ -1043,6 +1043,59 @@ const SETTINGS_SCHEMA = {
},
},
},
lsp: {
type: 'object',
label: 'LSP',
category: 'LSP',
requiresRestart: true,
default: {},
description:
'Settings for the native Language Server Protocol integration.',
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
label: 'Enable LSP',
category: 'LSP',
requiresRestart: true,
default: false,
description:
'Enable the native LSP client to connect to language servers discovered in the workspace.',
showInDialog: false,
},
allowed: {
type: 'array',
label: 'Allow LSP Servers',
category: 'LSP',
requiresRestart: true,
default: undefined as string[] | undefined,
description:
'Optional allowlist of LSP server names. If set, only matching servers will start.',
showInDialog: false,
},
excluded: {
type: 'array',
label: 'Exclude LSP Servers',
category: 'LSP',
requiresRestart: true,
default: undefined as string[] | undefined,
description:
'Optional blocklist of LSP server names that should not start.',
showInDialog: false,
},
languageServers: {
type: 'object',
label: 'LSP Language Servers',
category: 'LSP',
requiresRestart: true,
default: {} as Record<string, unknown>,
description:
'Inline LSP server configuration (same format as .lsp.json).',
showInDialog: false,
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
},
},
},
useSmartEdit: {
type: 'boolean',
label: 'Use Smart Edit',

View File

@@ -254,6 +254,8 @@ export async function main() {
[],
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
argv,
undefined,
{ startLsp: false },
);
if (!settings.merged.security?.auth?.useExternal) {

View File

@@ -0,0 +1,391 @@
import * as cp from 'node:child_process';
import * as net from 'node:net';
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
timer: NodeJS.Timeout;
}
class JsonRpcConnection {
private buffer = '';
private nextId = 1;
private disposed = false;
private pendingRequests = new Map<string | number, PendingRequest>();
private notificationHandlers: Array<(notification: JsonRpcMessage) => void> =
[];
private requestHandlers: Array<
(request: JsonRpcMessage) => Promise<unknown>
> = [];
constructor(
private readonly writer: (data: string) => void,
private readonly disposer?: () => void,
) {}
listen(readable: NodeJS.ReadableStream): void {
readable.on('data', (chunk: Buffer) => this.handleData(chunk));
readable.on('error', (error) =>
this.disposePending(
error instanceof Error ? error : new Error(String(error)),
),
);
}
send(message: JsonRpcMessage): void {
this.writeMessage(message);
}
onNotification(handler: (notification: JsonRpcMessage) => void): void {
this.notificationHandlers.push(handler);
}
onRequest(handler: (request: JsonRpcMessage) => Promise<unknown>): void {
this.requestHandlers.push(handler);
}
async initialize(params: unknown): Promise<unknown> {
return this.sendRequest('initialize', params);
}
async shutdown(): Promise<void> {
try {
await this.sendRequest('shutdown', {});
} catch (_error) {
// Ignore shutdown errors the server may already be gone.
} finally {
this.end();
}
}
request(method: string, params: unknown): Promise<unknown> {
return this.sendRequest(method, params);
}
end(): void {
if (this.disposed) {
return;
}
this.disposed = true;
this.disposePending();
this.disposer?.();
}
private sendRequest(method: string, params: unknown): Promise<unknown> {
if (this.disposed) {
return Promise.resolve(undefined);
}
const id = this.nextId++;
const payload: JsonRpcMessage = {
jsonrpc: '2.0',
id,
method,
params,
};
const requestPromise = new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`LSP request timeout: ${method}`));
}, 15000);
this.pendingRequests.set(id, { resolve, reject, timer });
});
this.writeMessage(payload);
return requestPromise;
}
private async handleServerRequest(message: JsonRpcMessage): Promise<void> {
const handler = this.requestHandlers[this.requestHandlers.length - 1];
if (!handler) {
this.writeMessage({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32601,
message: `Method not supported: ${message.method}`,
},
});
return;
}
try {
const result = await handler(message);
this.writeMessage({
jsonrpc: '2.0',
id: message.id,
result: result ?? null,
});
} catch (error) {
this.writeMessage({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32603,
message: (error as Error).message ?? 'Internal error',
},
});
}
}
private handleData(chunk: Buffer): void {
if (this.disposed) {
return;
}
this.buffer += chunk.toString('utf8');
while (true) {
const headerEnd = this.buffer.indexOf('\r\n\r\n');
if (headerEnd === -1) {
break;
}
const header = this.buffer.slice(0, headerEnd);
const lengthMatch = /Content-Length:\s*(\d+)/i.exec(header);
if (!lengthMatch) {
this.buffer = this.buffer.slice(headerEnd + 4);
continue;
}
const contentLength = Number(lengthMatch[1]);
const messageStart = headerEnd + 4;
const messageEnd = messageStart + contentLength;
if (this.buffer.length < messageEnd) {
break;
}
const body = this.buffer.slice(messageStart, messageEnd);
this.buffer = this.buffer.slice(messageEnd);
try {
const message = JSON.parse(body);
this.routeMessage(message);
} catch {
// ignore malformed messages
}
}
}
private routeMessage(message: JsonRpcMessage): void {
if (typeof message?.id !== 'undefined' && !message.method) {
const pending = this.pendingRequests.get(message.id);
if (!pending) {
return;
}
clearTimeout(pending.timer);
this.pendingRequests.delete(message.id);
if (message.error) {
pending.reject(
new Error(message.error.message || 'LSP request failed'),
);
} else {
pending.resolve(message.result);
}
return;
}
if (message?.method && typeof message.id !== 'undefined') {
void this.handleServerRequest(message);
return;
}
if (message?.method) {
for (const handler of this.notificationHandlers) {
try {
handler(message);
} catch {
// ignore handler errors
}
}
}
}
private writeMessage(message: JsonRpcMessage): void {
if (this.disposed) {
return;
}
const json = JSON.stringify(message);
const header = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n`;
this.writer(header + json);
}
private disposePending(error?: Error): void {
for (const [, pending] of Array.from(this.pendingRequests)) {
clearTimeout(pending.timer);
pending.reject(error ?? new Error('LSP connection closed'));
}
this.pendingRequests.clear();
}
}
interface LspConnection {
connection: JsonRpcConnection;
process?: cp.ChildProcess;
socket?: net.Socket;
}
interface SocketConnectionOptions {
host?: string;
port?: number;
path?: string;
}
interface JsonRpcMessage {
jsonrpc: string;
id?: number | string;
method?: string;
params?: unknown;
result?: unknown;
error?: {
code: number;
message: string;
data?: unknown;
};
}
export class LspConnectionFactory {
/**
* 创建基于 stdio 的 LSP 连接
*/
static async createStdioConnection(
command: string,
args: string[],
options?: cp.SpawnOptions,
timeoutMs = 10000,
): Promise<LspConnection> {
return new Promise((resolve, reject) => {
const spawnOptions: cp.SpawnOptions = {
stdio: 'pipe',
...options,
};
const processInstance = cp.spawn(command, args, spawnOptions);
const timeoutId = setTimeout(() => {
reject(new Error('LSP server spawn timeout'));
if (!processInstance.killed) {
processInstance.kill();
}
}, timeoutMs);
processInstance.once('error', (error) => {
clearTimeout(timeoutId);
reject(new Error(`Failed to spawn LSP server: ${error.message}`));
});
processInstance.once('spawn', () => {
clearTimeout(timeoutId);
if (!processInstance.stdout || !processInstance.stdin) {
reject(new Error('LSP server stdio not available'));
return;
}
const connection = new JsonRpcConnection(
(payload) => processInstance.stdin?.write(payload),
() => processInstance.stdin?.end(),
);
connection.listen(processInstance.stdout);
processInstance.once('exit', () => connection.end());
processInstance.once('close', () => connection.end());
resolve({
connection,
process: processInstance,
});
});
});
}
/**
* 创建基于 TCP 的 LSP 连接
*/
static async createTcpConnection(
host: string,
port: number,
timeoutMs = 10000,
): Promise<LspConnection> {
return LspConnectionFactory.createSocketConnection(
{ host, port },
timeoutMs,
);
}
/**
* 创建基于 socket 的 LSP 连接(支持 TCP 或 unix socket
*/
static async createSocketConnection(
options: SocketConnectionOptions,
timeoutMs = 10000,
): Promise<LspConnection> {
return new Promise((resolve, reject) => {
const socketOptions = options.path
? { path: options.path }
: { host: options.host ?? '127.0.0.1', port: options.port };
if (!('path' in socketOptions) && !socketOptions.port) {
reject(new Error('Socket transport requires port or path'));
return;
}
const socket = net.createConnection(socketOptions);
const timeoutId = setTimeout(() => {
reject(new Error('LSP server connection timeout'));
socket.destroy();
}, timeoutMs);
const onError = (error: Error) => {
clearTimeout(timeoutId);
reject(new Error(`Failed to connect to LSP server: ${error.message}`));
};
socket.once('error', onError);
socket.on('connect', () => {
clearTimeout(timeoutId);
socket.off('error', onError);
const connection = new JsonRpcConnection(
(payload) => socket.write(payload),
() => socket.destroy(),
);
connection.listen(socket);
socket.once('close', () => connection.end());
socket.once('error', () => connection.end());
resolve({
connection,
socket,
});
});
});
}
/**
* 关闭 LSP 连接
*/
static async closeConnection(lspConnection: LspConnection): Promise<void> {
if (lspConnection.connection) {
try {
await lspConnection.connection.shutdown();
} catch (e) {
console.warn('LSP shutdown failed:', e);
} finally {
lspConnection.connection.end();
}
}
if (lspConnection.process && !lspConnection.process.killed) {
lspConnection.process.kill();
}
if (lspConnection.socket && !lspConnection.socket.destroyed) {
lspConnection.socket.destroy();
}
}
}

View File

@@ -0,0 +1,127 @@
import { NativeLspService } from './NativeLspService.js';
import { EventEmitter } from 'events';
import type {
Config as CoreConfig,
WorkspaceContext,
FileDiscoveryService,
IdeContextStore,
} from '@qwen-code/qwen-code-core';
// 模拟依赖项
class MockConfig {
rootPath = '/test/workspace';
isTrustedFolder(): boolean {
return true;
}
get(_key: string) {
return undefined;
}
getProjectRoot(): string {
return this.rootPath;
}
}
class MockWorkspaceContext {
rootPath = '/test/workspace';
async fileExists(_path: string): Promise<boolean> {
return _path.endsWith('.json') || _path.includes('package.json');
}
async readFile(_path: string): Promise<string> {
if (_path.includes('.lsp.json')) {
return JSON.stringify({
typescript: {
command: 'typescript-language-server',
args: ['--stdio'],
transport: 'stdio',
},
});
}
return '{}';
}
resolvePath(_path: string): string {
return this.rootPath + '/' + _path;
}
isPathWithinWorkspace(_path: string): boolean {
return true;
}
getDirectories(): string[] {
return [this.rootPath];
}
}
class MockFileDiscoveryService {
async discoverFiles(_root: string, _options: unknown): Promise<string[]> {
// 模拟发现一些文件
return [
'/test/workspace/src/index.ts',
'/test/workspace/src/utils.ts',
'/test/workspace/server.py',
'/test/workspace/main.go',
];
}
shouldIgnoreFile(): boolean {
return false;
}
}
class MockIdeContextStore {
// 模拟 IDE 上下文存储
}
describe('NativeLspService', () => {
let lspService: NativeLspService;
let mockConfig: MockConfig;
let mockWorkspace: MockWorkspaceContext;
let mockFileDiscovery: MockFileDiscoveryService;
let mockIdeStore: MockIdeContextStore;
let eventEmitter: EventEmitter;
beforeEach(() => {
mockConfig = new MockConfig();
mockWorkspace = new MockWorkspaceContext();
mockFileDiscovery = new MockFileDiscoveryService();
mockIdeStore = new MockIdeContextStore();
eventEmitter = new EventEmitter();
lspService = new NativeLspService(
mockConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
);
});
test('should initialize correctly', () => {
expect(lspService).toBeDefined();
});
test('should detect languages from workspace files', async () => {
// 这个测试需要修改,因为我们无法直接访问私有方法
await lspService.discoverAndPrepare();
const status = lspService.getStatus();
// 检查服务是否已准备就绪
expect(status).toBeDefined();
});
test('should merge built-in presets with user configs', async () => {
await lspService.discoverAndPrepare();
const status = lspService.getStatus();
// 检查服务是否已准备就绪
expect(status).toBeDefined();
});
});
// 注意:实际的单元测试需要适当的测试框架配置
// 这里只是一个结构示例

File diff suppressed because it is too large Load Diff

View File

@@ -61,6 +61,11 @@ import { ToolRegistry } from '../tools/tool-registry.js';
import { WebFetchTool } from '../tools/web-fetch.js';
import { WebSearchTool } from '../tools/web-search/index.js';
import { WriteFileTool } from '../tools/write-file.js';
import { LspWorkspaceSymbolTool } from '../tools/lsp-workspace-symbol.js';
import { LspGoToDefinitionTool } from '../tools/lsp-go-to-definition.js';
import { LspFindReferencesTool } from '../tools/lsp-find-references.js';
import { LspTool } from '../tools/lsp.js';
import type { LspClient } from '../lsp/types.js';
// Other modules
import { ideContextStore } from '../ide/ideContext.js';
@@ -287,6 +292,12 @@ export interface ConfigParameters {
toolCallCommand?: string;
mcpServerCommand?: string;
mcpServers?: Record<string, MCPServerConfig>;
lsp?: {
enabled?: boolean;
allowed?: string[];
excluded?: string[];
};
lspClient?: LspClient;
userMemory?: string;
geminiMdFileCount?: number;
approvalMode?: ApprovalMode;
@@ -429,6 +440,10 @@ export class Config {
private readonly toolCallCommand: string | undefined;
private readonly mcpServerCommand: string | undefined;
private mcpServers: Record<string, MCPServerConfig> | undefined;
private readonly lspEnabled: boolean;
private readonly lspAllowed?: string[];
private readonly lspExcluded?: string[];
private lspClient?: LspClient;
private sessionSubagents: SubagentConfig[];
private userMemory: string;
private sdkMode: boolean;
@@ -534,6 +549,10 @@ export class Config {
this.toolCallCommand = params.toolCallCommand;
this.mcpServerCommand = params.mcpServerCommand;
this.mcpServers = params.mcpServers;
this.lspEnabled = params.lsp?.enabled ?? false;
this.lspAllowed = params.lsp?.allowed?.filter(Boolean);
this.lspExcluded = params.lsp?.excluded?.filter(Boolean);
this.lspClient = params.lspClient;
this.sessionSubagents = params.sessionSubagents ?? [];
this.sdkMode = params.sdkMode ?? false;
this.userMemory = params.userMemory ?? '';
@@ -1031,6 +1050,32 @@ export class Config {
this.mcpServers = { ...this.mcpServers, ...servers };
}
isLspEnabled(): boolean {
return this.lspEnabled;
}
getLspAllowed(): string[] | undefined {
return this.lspAllowed;
}
getLspExcluded(): string[] | undefined {
return this.lspExcluded;
}
getLspClient(): LspClient | undefined {
return this.lspClient;
}
/**
* Allows wiring an LSP client after Config construction but before initialize().
*/
setLspClient(client: LspClient | undefined): void {
if (this.initialized) {
throw new Error('Cannot set LSP client after initialization');
}
this.lspClient = client;
}
getSessionSubagents(): SubagentConfig[] {
return this.sessionSubagents;
}
@@ -1538,6 +1583,14 @@ export class Config {
if (this.getWebSearchConfig()) {
registerCoreTool(WebSearchTool, this);
}
if (this.isLspEnabled() && this.getLspClient()) {
// Register the unified LSP tool (recommended)
registerCoreTool(LspTool, this);
// Keep legacy tools for backward compatibility
registerCoreTool(LspGoToDefinitionTool, this);
registerCoreTool(LspFindReferencesTool, this);
registerCoreTool(LspWorkspaceSymbolTool, this);
}
await registry.discoverAllTools();
console.debug('ToolRegistry created', registry.getAllToolNames());

View File

@@ -111,6 +111,7 @@ export * from './skills/index.js';
// Export prompt logic
export * from './prompts/mcp-prompts.js';
export * from './lsp/types.js';
// Export specific tool logic
export * from './tools/read-file.js';
@@ -125,6 +126,8 @@ export * from './tools/memoryTool.js';
export * from './tools/shell.js';
export * from './tools/web-search/index.js';
export * from './tools/read-many-files.js';
export * from './tools/lsp-go-to-definition.js';
export * from './tools/lsp-find-references.js';
export * from './tools/mcp-client.js';
export * from './tools/mcp-client-manager.js';
export * from './tools/mcp-tool.js';

View File

@@ -0,0 +1,178 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export interface LspPosition {
line: number;
character: number;
}
export interface LspRange {
start: LspPosition;
end: LspPosition;
}
export interface LspLocation {
uri: string;
range: LspRange;
}
export interface LspLocationWithServer extends LspLocation {
serverName?: string;
}
export interface LspSymbolInformation {
name: string;
kind?: string;
location: LspLocation;
containerName?: string;
serverName?: string;
}
export interface LspReference extends LspLocationWithServer {
readonly serverName?: string;
}
export interface LspDefinition extends LspLocationWithServer {
readonly serverName?: string;
}
/**
* Hover result containing documentation or type information.
*/
export interface LspHoverResult {
/** The hover content as a string (normalized from MarkupContent/MarkedString). */
contents: string;
/** Optional range that the hover applies to. */
range?: LspRange;
/** The LSP server that provided this result. */
serverName?: string;
}
/**
* Call hierarchy item representing a function, method, or callable.
*/
export interface LspCallHierarchyItem {
/** The name of this item. */
name: string;
/** The kind of this item (function, method, constructor, etc.) as readable string. */
kind?: string;
/** The raw numeric SymbolKind from LSP, preserved for server communication. */
rawKind?: number;
/** Additional details like signature or file path. */
detail?: string;
/** The URI of the document containing this item. */
uri: string;
/** The full range of this item. */
range: LspRange;
/** The range that should be selected when navigating to this item. */
selectionRange: LspRange;
/** Opaque data used by the server for subsequent calls. */
data?: unknown;
/** The LSP server that provided this item. */
serverName?: string;
}
/**
* Incoming call representing a function that calls the target.
*/
export interface LspCallHierarchyIncomingCall {
/** The caller item. */
from: LspCallHierarchyItem;
/** The ranges where the call occurs within the caller. */
fromRanges: LspRange[];
}
/**
* Outgoing call representing a function called by the target.
*/
export interface LspCallHierarchyOutgoingCall {
/** The callee item. */
to: LspCallHierarchyItem;
/** The ranges where the call occurs within the caller. */
fromRanges: LspRange[];
}
export interface LspClient {
/**
* Search for symbols across the workspace.
*/
workspaceSymbols(
query: string,
limit?: number,
): Promise<LspSymbolInformation[]>;
/**
* Get hover information (documentation, type info) for a symbol.
*/
hover(
location: LspLocation,
serverName?: string,
): Promise<LspHoverResult | null>;
/**
* Get all symbols in a document.
*/
documentSymbols(
uri: string,
serverName?: string,
limit?: number,
): Promise<LspSymbolInformation[]>;
/**
* Find where a symbol is defined.
*/
definitions(
location: LspLocation,
serverName?: string,
limit?: number,
): Promise<LspDefinition[]>;
/**
* Find implementations of an interface or abstract method.
*/
implementations(
location: LspLocation,
serverName?: string,
limit?: number,
): Promise<LspDefinition[]>;
/**
* Find all references to a symbol.
*/
references(
location: LspLocation,
serverName?: string,
includeDeclaration?: boolean,
limit?: number,
): Promise<LspReference[]>;
/**
* Prepare call hierarchy item at a position (functions/methods).
*/
prepareCallHierarchy(
location: LspLocation,
serverName?: string,
limit?: number,
): Promise<LspCallHierarchyItem[]>;
/**
* Find all functions/methods that call the given function.
*/
incomingCalls(
item: LspCallHierarchyItem,
serverName?: string,
limit?: number,
): Promise<LspCallHierarchyIncomingCall[]>;
/**
* Find all functions/methods called by the given function.
*/
outgoingCalls(
item: LspCallHierarchyItem,
serverName?: string,
limit?: number,
): Promise<LspCallHierarchyOutgoingCall[]>;
}

View File

@@ -0,0 +1,308 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolDisplayNames, ToolNames } from './tool-names.js';
import type { Config } from '../config/config.js';
import type { LspClient, LspLocation, LspReference } from '../lsp/types.js';
export interface LspFindReferencesParams {
/**
* Symbol name to resolve if a file/position is not provided.
*/
symbol?: string;
/**
* File path (absolute or workspace-relative).
* Use together with `line` (1-based) and optional `character` (1-based).
*/
file?: string;
/**
* File URI (e.g., file:///path/to/file).
* Use together with `line` (1-based) and optional `character` (1-based).
*/
uri?: string;
/**
* 1-based line number when targeting a specific file location.
*/
line?: number;
/**
* 1-based character/column number when targeting a specific file location.
*/
character?: number;
/**
* Whether to include the declaration in results (default: false).
*/
includeDeclaration?: boolean;
/**
* Optional server name override.
*/
serverName?: string;
/**
* Optional maximum number of results.
*/
limit?: number;
}
type ResolvedTarget =
| {
location: LspLocation;
description: string;
serverName?: string;
fromSymbol: boolean;
}
| { error: string };
class LspFindReferencesInvocation extends BaseToolInvocation<
LspFindReferencesParams,
ToolResult
> {
constructor(
private readonly config: Config,
params: LspFindReferencesParams,
) {
super(params);
}
getDescription(): string {
if (this.params.symbol) {
return `LSP find-references查引用 for symbol "${this.params.symbol}"`;
}
if (this.params.file && this.params.line !== undefined) {
return `LSP find-references查引用 at ${this.params.file}:${this.params.line}:${this.params.character ?? 1}`;
}
if (this.params.uri && this.params.line !== undefined) {
return `LSP find-references查引用 at ${this.params.uri}:${this.params.line}:${this.params.character ?? 1}`;
}
return 'LSP find-references查引用';
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
const client = this.config.getLspClient();
if (!client || !this.config.isLspEnabled()) {
const message =
'LSP find-references is unavailable (LSP disabled or not initialized).';
return { llmContent: message, returnDisplay: message };
}
const target = await this.resolveTarget(client);
if ('error' in target) {
return { llmContent: target.error, returnDisplay: target.error };
}
const limit = this.params.limit ?? 50;
let references: LspReference[] = [];
try {
references = await client.references(
target.location,
target.serverName,
this.params.includeDeclaration ?? false,
limit,
);
} catch (error) {
const message = `LSP find-references failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!references.length) {
const message = `No references found for ${target.description}.`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const lines = references
.slice(0, limit)
.map(
(reference, index) =>
`${index + 1}. ${this.formatLocation(reference, workspaceRoot)}`,
);
const heading = `References for ${target.description}:`;
return {
llmContent: [heading, ...lines].join('\n'),
returnDisplay: lines.join('\n'),
};
}
private async resolveTarget(
client: Pick<LspClient, 'workspaceSymbols'>,
): Promise<ResolvedTarget> {
const workspaceRoot = this.config.getProjectRoot();
const lineProvided = typeof this.params.line === 'number';
const character = this.params.character ?? 1;
if ((this.params.file || this.params.uri) && lineProvided) {
const uri = this.resolveUri(workspaceRoot);
if (!uri) {
return {
error:
'A valid file path or URI is required when specifying a line/character.',
};
}
const position = {
line: Math.max(0, Math.floor((this.params.line ?? 1) - 1)),
character: Math.max(0, Math.floor(character - 1)),
};
const location: LspLocation = {
uri,
range: { start: position, end: position },
};
const description = this.formatLocation(
{ ...location, serverName: this.params.serverName },
workspaceRoot,
);
return {
location,
description,
serverName: this.params.serverName,
fromSymbol: false,
};
}
if (this.params.symbol) {
try {
const symbols = await client.workspaceSymbols(this.params.symbol, 5);
if (!symbols.length) {
return {
error: `No symbols found for query "${this.params.symbol}".`,
};
}
const top = symbols[0];
return {
location: top.location,
description: `symbol "${this.params.symbol}"`,
serverName: this.params.serverName ?? top.serverName,
fromSymbol: true,
};
} catch (error) {
return {
error: `Workspace symbol search failed: ${
(error as Error)?.message || String(error)
}`,
};
}
}
return {
error:
'Provide a symbol name or a file plus line (and optional character) to use find-references.',
};
}
private resolveUri(workspaceRoot: string): string | null {
if (this.params.uri) {
if (
this.params.uri.startsWith('file://') ||
this.params.uri.includes('://')
) {
return this.params.uri;
}
const absoluteUriPath = path.isAbsolute(this.params.uri)
? this.params.uri
: path.resolve(workspaceRoot, this.params.uri);
return pathToFileURL(absoluteUriPath).toString();
}
if (this.params.file) {
const absolutePath = path.isAbsolute(this.params.file)
? this.params.file
: path.resolve(workspaceRoot, this.params.file);
return pathToFileURL(absolutePath).toString();
}
return null;
}
private formatLocation(
location: LspReference | (LspLocation & { serverName?: string }),
workspaceRoot: string,
): string {
const start = location.range.start;
let filePath = location.uri;
if (filePath.startsWith('file://')) {
filePath = fileURLToPath(filePath);
filePath = path.relative(workspaceRoot, filePath) || '.';
}
const serverSuffix =
location.serverName && location.serverName !== ''
? ` [${location.serverName}]`
: '';
return `${filePath}:${(start.line ?? 0) + 1}:${(start.character ?? 0) + 1}${serverSuffix}`;
}
}
export class LspFindReferencesTool extends BaseDeclarativeTool<
LspFindReferencesParams,
ToolResult
> {
static readonly Name = ToolNames.LSP_FIND_REFERENCES;
constructor(private readonly config: Config) {
super(
LspFindReferencesTool.Name,
ToolDisplayNames.LSP_FIND_REFERENCES,
'Use LSP find-references for a symbol or a specific file location查引用优先于 grep 搜索)。',
Kind.Other,
{
type: 'object',
properties: {
symbol: {
type: 'string',
description:
'Symbol name to resolve when a file/position is not provided.',
},
file: {
type: 'string',
description:
'File path (absolute or workspace-relative). Requires `line`.',
},
uri: {
type: 'string',
description:
'File URI (file:///...). Requires `line` when provided.',
},
line: {
type: 'number',
description: '1-based line number for the target location.',
},
character: {
type: 'number',
description:
'1-based character/column number for the target location.',
},
includeDeclaration: {
type: 'boolean',
description:
'Include the declaration itself when looking up references.',
},
serverName: {
type: 'string',
description: 'Optional LSP server name to target.',
},
limit: {
type: 'number',
description: 'Optional maximum number of results to return.',
},
},
},
false,
false,
);
}
protected createInvocation(
params: LspFindReferencesParams,
): ToolInvocation<LspFindReferencesParams, ToolResult> {
return new LspFindReferencesInvocation(this.config, params);
}
}

View File

@@ -0,0 +1,308 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolDisplayNames, ToolNames } from './tool-names.js';
import type { Config } from '../config/config.js';
import type { LspClient, LspDefinition, LspLocation } from '../lsp/types.js';
export interface LspGoToDefinitionParams {
/**
* Symbol name to resolve if a file/position is not provided.
*/
symbol?: string;
/**
* File path (absolute or workspace-relative).
* Use together with `line` (1-based) and optional `character` (1-based).
*/
file?: string;
/**
* File URI (e.g., file:///path/to/file).
* Use together with `line` (1-based) and optional `character` (1-based).
*/
uri?: string;
/**
* 1-based line number when targeting a specific file location.
*/
line?: number;
/**
* 1-based character/column number when targeting a specific file location.
*/
character?: number;
/**
* Optional server name override.
*/
serverName?: string;
/**
* Optional maximum number of results.
*/
limit?: number;
}
type ResolvedTarget =
| {
location: LspLocation;
description: string;
serverName?: string;
fromSymbol: boolean;
}
| { error: string };
class LspGoToDefinitionInvocation extends BaseToolInvocation<
LspGoToDefinitionParams,
ToolResult
> {
constructor(
private readonly config: Config,
params: LspGoToDefinitionParams,
) {
super(params);
}
getDescription(): string {
if (this.params.symbol) {
return `LSP go-to-definition跳转定义 for symbol "${this.params.symbol}"`;
}
if (this.params.file && this.params.line !== undefined) {
return `LSP go-to-definition跳转定义 at ${this.params.file}:${this.params.line}:${this.params.character ?? 1}`;
}
if (this.params.uri && this.params.line !== undefined) {
return `LSP go-to-definition跳转定义 at ${this.params.uri}:${this.params.line}:${this.params.character ?? 1}`;
}
return 'LSP go-to-definition跳转定义';
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
const client = this.config.getLspClient();
if (!client || !this.config.isLspEnabled()) {
const message =
'LSP go-to-definition is unavailable (LSP disabled or not initialized).';
return { llmContent: message, returnDisplay: message };
}
const target = await this.resolveTarget(client);
if ('error' in target) {
return { llmContent: target.error, returnDisplay: target.error };
}
const limit = this.params.limit ?? 20;
let definitions: LspDefinition[] = [];
try {
definitions = await client.definitions(
target.location,
target.serverName,
limit,
);
} catch (error) {
const message = `LSP go-to-definition failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
// Fallback to the resolved symbol location if the server does not return definitions.
if (!definitions.length && target.fromSymbol) {
definitions = [
{
...target.location,
serverName: target.serverName,
},
];
}
if (!definitions.length) {
const message = `No definitions found for ${target.description}.`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const lines = definitions
.slice(0, limit)
.map(
(definition, index) =>
`${index + 1}. ${this.formatLocation(definition, workspaceRoot)}`,
);
const heading = `Definitions for ${target.description}:`;
return {
llmContent: [heading, ...lines].join('\n'),
returnDisplay: lines.join('\n'),
};
}
private async resolveTarget(
client: Pick<LspClient, 'workspaceSymbols'>,
): Promise<ResolvedTarget> {
const workspaceRoot = this.config.getProjectRoot();
const lineProvided = typeof this.params.line === 'number';
const character = this.params.character ?? 1;
if ((this.params.file || this.params.uri) && lineProvided) {
const uri = this.resolveUri(workspaceRoot);
if (!uri) {
return {
error:
'A valid file path or URI is required when specifying a line/character.',
};
}
const position = {
line: Math.max(0, Math.floor((this.params.line ?? 1) - 1)),
character: Math.max(0, Math.floor(character - 1)),
};
const location: LspLocation = {
uri,
range: { start: position, end: position },
};
const description = this.formatLocation(
{ ...location, serverName: this.params.serverName },
workspaceRoot,
);
return {
location,
description,
serverName: this.params.serverName,
fromSymbol: false,
};
}
if (this.params.symbol) {
try {
const symbols = await client.workspaceSymbols(this.params.symbol, 5);
if (!symbols.length) {
return {
error: `No symbols found for query "${this.params.symbol}".`,
};
}
const top = symbols[0];
return {
location: top.location,
description: `symbol "${this.params.symbol}"`,
serverName: this.params.serverName ?? top.serverName,
fromSymbol: true,
};
} catch (error) {
return {
error: `Workspace symbol search failed: ${
(error as Error)?.message || String(error)
}`,
};
}
}
return {
error:
'Provide a symbol name or a file plus line (and optional character) to use go-to-definition.',
};
}
private resolveUri(workspaceRoot: string): string | null {
if (this.params.uri) {
if (
this.params.uri.startsWith('file://') ||
this.params.uri.includes('://')
) {
return this.params.uri;
}
const absoluteUriPath = path.isAbsolute(this.params.uri)
? this.params.uri
: path.resolve(workspaceRoot, this.params.uri);
return pathToFileURL(absoluteUriPath).toString();
}
if (this.params.file) {
const absolutePath = path.isAbsolute(this.params.file)
? this.params.file
: path.resolve(workspaceRoot, this.params.file);
return pathToFileURL(absolutePath).toString();
}
return null;
}
private formatLocation(
location: LspDefinition | (LspLocation & { serverName?: string }),
workspaceRoot: string,
): string {
const start = location.range.start;
let filePath = location.uri;
if (filePath.startsWith('file://')) {
filePath = fileURLToPath(filePath);
filePath = path.relative(workspaceRoot, filePath) || '.';
}
const serverSuffix =
location.serverName && location.serverName !== ''
? ` [${location.serverName}]`
: '';
return `${filePath}:${(start.line ?? 0) + 1}:${(start.character ?? 0) + 1}${serverSuffix}`;
}
}
export class LspGoToDefinitionTool extends BaseDeclarativeTool<
LspGoToDefinitionParams,
ToolResult
> {
static readonly Name = ToolNames.LSP_GO_TO_DEFINITION;
constructor(private readonly config: Config) {
super(
LspGoToDefinitionTool.Name,
ToolDisplayNames.LSP_GO_TO_DEFINITION,
'Use LSP go-to-definition for a symbol or a specific file location跳转定义优先于 grep 搜索)。',
Kind.Other,
{
type: 'object',
properties: {
symbol: {
type: 'string',
description:
'Symbol name to resolve when a file/position is not provided.',
},
file: {
type: 'string',
description:
'File path (absolute or workspace-relative). Requires `line`.',
},
uri: {
type: 'string',
description:
'File URI (file:///...). Requires `line` when provided.',
},
line: {
type: 'number',
description: '1-based line number for the target location.',
},
character: {
type: 'number',
description:
'1-based character/column number for the target location.',
},
serverName: {
type: 'string',
description: 'Optional LSP server name to target.',
},
limit: {
type: 'number',
description: 'Optional maximum number of results to return.',
},
},
},
false,
false,
);
}
protected createInvocation(
params: LspGoToDefinitionParams,
): ToolInvocation<LspGoToDefinitionParams, ToolResult> {
return new LspGoToDefinitionInvocation(this.config, params);
}
}

View File

@@ -0,0 +1,180 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolDisplayNames, ToolNames } from './tool-names.js';
import type { Config } from '../config/config.js';
import type { LspSymbolInformation } from '../lsp/types.js';
export interface LspWorkspaceSymbolParams {
/**
* Query string to search symbols (e.g., function or class name).
*/
query: string;
/**
* Maximum number of results to return.
*/
limit?: number;
}
class LspWorkspaceSymbolInvocation extends BaseToolInvocation<
LspWorkspaceSymbolParams,
ToolResult
> {
constructor(
private readonly config: Config,
params: LspWorkspaceSymbolParams,
) {
super(params);
}
getDescription(): string {
return `LSP workspace symbol search按名称找定义/实现/引用) for "${this.params.query}"`;
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
const client = this.config.getLspClient();
if (!client || !this.config.isLspEnabled()) {
const message =
'LSP workspace symbol search is unavailable (LSP disabled or not initialized).';
return { llmContent: message, returnDisplay: message };
}
const limit = this.params.limit ?? 20;
let symbols: LspSymbolInformation[] = [];
try {
symbols = await client.workspaceSymbols(this.params.query, limit);
} catch (error) {
const message = `LSP workspace symbol search failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!symbols.length) {
const message = `No symbols found for query "${this.params.query}".`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const lines = symbols.slice(0, limit).map((symbol, index) => {
const location = this.formatLocation(symbol, workspaceRoot);
const serverSuffix = symbol.serverName
? ` [${symbol.serverName}]`
: '';
const kind = symbol.kind ? ` (${symbol.kind})` : '';
const container = symbol.containerName
? ` in ${symbol.containerName}`
: '';
return `${index + 1}. ${symbol.name}${kind}${container} - ${location}${serverSuffix}`;
});
const heading = `Found ${Math.min(symbols.length, limit)} of ${
symbols.length
} symbols for query "${this.params.query}":`;
let referenceSection = '';
const topSymbol = symbols[0];
if (topSymbol) {
try {
const referenceLimit = Math.min(20, Math.max(limit, 5));
const references = await client.references(
topSymbol.location,
topSymbol.serverName,
false,
referenceLimit,
);
if (references.length > 0) {
const refLines = references.map((ref, index) => {
const location = this.formatLocation(
{ location: ref, name: '', kind: undefined },
workspaceRoot,
);
const serverSuffix = ref.serverName
? ` [${ref.serverName}]`
: '';
return `${index + 1}. ${location}${serverSuffix}`;
});
referenceSection = [
'',
`References for top match (${topSymbol.name}):`,
...refLines,
].join('\n');
}
} catch (error) {
referenceSection = `\nReferences lookup failed: ${
(error as Error)?.message || String(error)
}`;
}
}
const llmParts = referenceSection
? [heading, ...lines, referenceSection]
: [heading, ...lines];
const displayParts = referenceSection
? [...lines, referenceSection]
: [...lines];
return {
llmContent: llmParts.join('\n'),
returnDisplay: displayParts.join('\n'),
};
}
private formatLocation(symbol: LspSymbolInformation, workspaceRoot: string) {
const { uri, range } = symbol.location;
let filePath = uri;
if (uri.startsWith('file://')) {
filePath = fileURLToPath(uri);
filePath = path.relative(workspaceRoot, filePath) || '.';
}
const line = (range.start.line ?? 0) + 1;
const character = (range.start.character ?? 0) + 1;
return `${filePath}:${line}:${character}`;
}
}
export class LspWorkspaceSymbolTool extends BaseDeclarativeTool<
LspWorkspaceSymbolParams,
ToolResult
> {
static readonly Name = ToolNames.LSP_WORKSPACE_SYMBOL;
constructor(private readonly config: Config) {
super(
LspWorkspaceSymbolTool.Name,
ToolDisplayNames.LSP_WORKSPACE_SYMBOL,
'Search workspace symbols via LSP查找定义/实现/引用,按名称定位符号,优先于 grep。',
Kind.Other,
{
type: 'object',
properties: {
query: {
type: 'string',
description:
'Symbol name query, e.g., function/class/variable name to search.',
},
limit: {
type: 'number',
description: 'Optional maximum number of results to return.',
},
},
required: ['query'],
},
false,
false,
);
}
protected createInvocation(
params: LspWorkspaceSymbolParams,
): ToolInvocation<LspWorkspaceSymbolParams, ToolResult> {
return new LspWorkspaceSymbolInvocation(this.config, params);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,960 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolDisplayNames, ToolNames } from './tool-names.js';
import type { Config } from '../config/config.js';
import type {
LspCallHierarchyIncomingCall,
LspCallHierarchyItem,
LspCallHierarchyOutgoingCall,
LspClient,
LspDefinition,
LspLocation,
LspRange,
LspReference,
LspSymbolInformation,
} from '../lsp/types.js';
/**
* Supported LSP operations.
*/
export type LspOperation =
| 'goToDefinition'
| 'findReferences'
| 'hover'
| 'documentSymbol'
| 'workspaceSymbol'
| 'goToImplementation'
| 'prepareCallHierarchy'
| 'incomingCalls'
| 'outgoingCalls';
/**
* Parameters for the unified LSP tool.
*/
export interface LspToolParams {
/** Operation to perform. */
operation: LspOperation;
/** File path (absolute or workspace-relative). */
filePath?: string;
/** 1-based line number when targeting a specific file location. */
line?: number;
/** 1-based character/column number when targeting a specific file location. */
character?: number;
/** Whether to include the declaration in reference results. */
includeDeclaration?: boolean;
/** Query string for workspace symbol search. */
query?: string;
/** Call hierarchy item from a previous call hierarchy operation. */
callHierarchyItem?: LspCallHierarchyItem;
/** Optional server name override. */
serverName?: string;
/** Optional maximum number of results. */
limit?: number;
}
type ResolvedTarget =
| {
location: LspLocation;
description: string;
}
| { error: string };
/** Operations that require filePath and line. */
const LOCATION_REQUIRED_OPERATIONS = new Set<LspOperation>([
'goToDefinition',
'findReferences',
'hover',
'goToImplementation',
'prepareCallHierarchy',
]);
/** Operations that only require filePath. */
const FILE_REQUIRED_OPERATIONS = new Set<LspOperation>(['documentSymbol']);
/** Operations that require query. */
const QUERY_REQUIRED_OPERATIONS = new Set<LspOperation>(['workspaceSymbol']);
/** Operations that require callHierarchyItem. */
const ITEM_REQUIRED_OPERATIONS = new Set<LspOperation>([
'incomingCalls',
'outgoingCalls',
]);
class LspToolInvocation extends BaseToolInvocation<LspToolParams, ToolResult> {
constructor(
private readonly config: Config,
params: LspToolParams,
) {
super(params);
}
getDescription(): string {
const operationLabel = this.getOperationLabel();
if (this.params.operation === 'workspaceSymbol') {
return `LSP ${operationLabel} for "${this.params.query ?? ''}"`;
}
if (this.params.operation === 'documentSymbol') {
return this.params.filePath
? `LSP ${operationLabel} for ${this.params.filePath}`
: `LSP ${operationLabel}`;
}
if (
this.params.operation === 'incomingCalls' ||
this.params.operation === 'outgoingCalls'
) {
return `LSP ${operationLabel} for ${this.describeCallHierarchyItemShort()}`;
}
if (this.params.filePath && this.params.line !== undefined) {
return `LSP ${operationLabel} at ${this.params.filePath}:${this.params.line}:${this.params.character ?? 1}`;
}
if (this.params.filePath) {
return `LSP ${operationLabel} for ${this.params.filePath}`;
}
return `LSP ${operationLabel}`;
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
const client = this.config.getLspClient();
if (!client || !this.config.isLspEnabled()) {
const message = `LSP ${this.getOperationLabel()} is unavailable (LSP disabled or not initialized).`;
return { llmContent: message, returnDisplay: message };
}
switch (this.params.operation) {
case 'goToDefinition':
return this.executeDefinitions(client);
case 'findReferences':
return this.executeReferences(client);
case 'hover':
return this.executeHover(client);
case 'documentSymbol':
return this.executeDocumentSymbols(client);
case 'workspaceSymbol':
return this.executeWorkspaceSymbols(client);
case 'goToImplementation':
return this.executeImplementations(client);
case 'prepareCallHierarchy':
return this.executePrepareCallHierarchy(client);
case 'incomingCalls':
return this.executeIncomingCalls(client);
case 'outgoingCalls':
return this.executeOutgoingCalls(client);
default: {
const message = `Unsupported LSP operation: ${this.params.operation}`;
return { llmContent: message, returnDisplay: message };
}
}
}
private async executeDefinitions(client: LspClient): Promise<ToolResult> {
const target = this.resolveLocationTarget();
if ('error' in target) {
return { llmContent: target.error, returnDisplay: target.error };
}
const limit = this.params.limit ?? 20;
let definitions: LspDefinition[] = [];
try {
definitions = await client.definitions(
target.location,
this.params.serverName,
limit,
);
} catch (error) {
const message = `LSP go-to-definition failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!definitions.length) {
const message = `No definitions found for ${target.description}.`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const lines = definitions
.slice(0, limit)
.map(
(definition, index) =>
`${index + 1}. ${this.formatLocationWithServer(definition, workspaceRoot)}`,
);
const heading = `Definitions for ${target.description}:`;
return {
llmContent: [heading, ...lines].join('\n'),
returnDisplay: lines.join('\n'),
};
}
private async executeImplementations(client: LspClient): Promise<ToolResult> {
const target = this.resolveLocationTarget();
if ('error' in target) {
return { llmContent: target.error, returnDisplay: target.error };
}
const limit = this.params.limit ?? 20;
let implementations: LspDefinition[] = [];
try {
implementations = await client.implementations(
target.location,
this.params.serverName,
limit,
);
} catch (error) {
const message = `LSP go-to-implementation failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!implementations.length) {
const message = `No implementations found for ${target.description}.`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const lines = implementations
.slice(0, limit)
.map(
(implementation, index) =>
`${index + 1}. ${this.formatLocationWithServer(implementation, workspaceRoot)}`,
);
const heading = `Implementations for ${target.description}:`;
return {
llmContent: [heading, ...lines].join('\n'),
returnDisplay: lines.join('\n'),
};
}
private async executeReferences(client: LspClient): Promise<ToolResult> {
const target = this.resolveLocationTarget();
if ('error' in target) {
return { llmContent: target.error, returnDisplay: target.error };
}
const limit = this.params.limit ?? 50;
let references: LspReference[] = [];
try {
references = await client.references(
target.location,
this.params.serverName,
this.params.includeDeclaration ?? false,
limit,
);
} catch (error) {
const message = `LSP find-references failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!references.length) {
const message = `No references found for ${target.description}.`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const lines = references
.slice(0, limit)
.map(
(reference, index) =>
`${index + 1}. ${this.formatLocationWithServer(reference, workspaceRoot)}`,
);
const heading = `References for ${target.description}:`;
return {
llmContent: [heading, ...lines].join('\n'),
returnDisplay: lines.join('\n'),
};
}
private async executeHover(client: LspClient): Promise<ToolResult> {
const target = this.resolveLocationTarget();
if ('error' in target) {
return { llmContent: target.error, returnDisplay: target.error };
}
let hoverText = '';
try {
const result = await client.hover(
target.location,
this.params.serverName,
);
if (result) {
hoverText = result.contents ?? '';
}
} catch (error) {
const message = `LSP hover failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!hoverText || hoverText.trim().length === 0) {
const message = `No hover information found for ${target.description}.`;
return { llmContent: message, returnDisplay: message };
}
const heading = `Hover for ${target.description}:`;
const content = hoverText.trim();
return {
llmContent: `${heading}\n${content}`,
returnDisplay: content,
};
}
private async executeDocumentSymbols(client: LspClient): Promise<ToolResult> {
const workspaceRoot = this.config.getProjectRoot();
const filePath = this.params.filePath ?? '';
const uri = this.resolveUri(filePath, workspaceRoot);
if (!uri) {
const message = 'A valid filePath is required for document symbols.';
return { llmContent: message, returnDisplay: message };
}
const limit = this.params.limit ?? 50;
let symbols: LspSymbolInformation[] = [];
try {
symbols = await client.documentSymbols(
uri,
this.params.serverName,
limit,
);
} catch (error) {
const message = `LSP document symbols failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!symbols.length) {
const fileLabel = this.formatUriForDisplay(uri, workspaceRoot);
const message = `No document symbols found for ${fileLabel}.`;
return { llmContent: message, returnDisplay: message };
}
const lines = symbols.slice(0, limit).map((symbol, index) => {
const location = this.formatLocationWithoutServer(
symbol.location,
workspaceRoot,
);
const serverSuffix = symbol.serverName ? ` [${symbol.serverName}]` : '';
const kind = symbol.kind ? ` (${symbol.kind})` : '';
const container = symbol.containerName
? ` in ${symbol.containerName}`
: '';
return `${index + 1}. ${symbol.name}${kind}${container} - ${location}${serverSuffix}`;
});
const fileLabel = this.formatUriForDisplay(uri, workspaceRoot);
const heading = `Document symbols for ${fileLabel}:`;
return {
llmContent: [heading, ...lines].join('\n'),
returnDisplay: lines.join('\n'),
};
}
private async executeWorkspaceSymbols(
client: LspClient,
): Promise<ToolResult> {
const limit = this.params.limit ?? 20;
const query = this.params.query ?? '';
let symbols: LspSymbolInformation[] = [];
try {
symbols = await client.workspaceSymbols(query, limit);
} catch (error) {
const message = `LSP workspace symbol search failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!symbols.length) {
const message = `No symbols found for query "${query}".`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const lines = symbols.slice(0, limit).map((symbol, index) => {
const location = this.formatLocationWithoutServer(
symbol.location,
workspaceRoot,
);
const serverSuffix = symbol.serverName ? ` [${symbol.serverName}]` : '';
const kind = symbol.kind ? ` (${symbol.kind})` : '';
const container = symbol.containerName
? ` in ${symbol.containerName}`
: '';
return `${index + 1}. ${symbol.name}${kind}${container} - ${location}${serverSuffix}`;
});
const heading = `Found ${Math.min(symbols.length, limit)} of ${
symbols.length
} symbols for query "${query}":`;
// Also fetch references for the top match to provide additional context.
let referenceSection = '';
const topSymbol = symbols[0];
if (topSymbol) {
try {
const referenceLimit = Math.min(20, Math.max(limit, 5));
const references = await client.references(
topSymbol.location,
topSymbol.serverName,
false,
referenceLimit,
);
if (references.length > 0) {
const refLines = references.map((ref, index) => {
const location = this.formatLocationWithoutServer(
ref,
workspaceRoot,
);
const serverSuffix = ref.serverName ? ` [${ref.serverName}]` : '';
return `${index + 1}. ${location}${serverSuffix}`;
});
referenceSection = [
'',
`References for top match (${topSymbol.name}):`,
...refLines,
].join('\n');
}
} catch (error) {
referenceSection = `\nReferences lookup failed: ${
(error as Error)?.message || String(error)
}`;
}
}
const llmParts = referenceSection
? [heading, ...lines, referenceSection]
: [heading, ...lines];
const displayParts = referenceSection
? [...lines, referenceSection]
: [...lines];
return {
llmContent: llmParts.join('\n'),
returnDisplay: displayParts.join('\n'),
};
}
private async executePrepareCallHierarchy(
client: LspClient,
): Promise<ToolResult> {
const target = this.resolveLocationTarget();
if ('error' in target) {
return { llmContent: target.error, returnDisplay: target.error };
}
const limit = this.params.limit ?? 20;
let items: LspCallHierarchyItem[] = [];
try {
items = await client.prepareCallHierarchy(
target.location,
this.params.serverName,
limit,
);
} catch (error) {
const message = `LSP call hierarchy prepare failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!items.length) {
const message = `No call hierarchy items found for ${target.description}.`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const slicedItems = items.slice(0, limit);
const lines = slicedItems.map((item, index) =>
this.formatCallHierarchyItemLine(item, index, workspaceRoot),
);
const heading = `Call hierarchy items for ${target.description}:`;
const jsonSection = this.formatJsonSection(
'Call hierarchy items (JSON)',
slicedItems,
);
return {
llmContent: [heading, ...lines].join('\n') + jsonSection,
returnDisplay: lines.join('\n'),
};
}
private async executeIncomingCalls(client: LspClient): Promise<ToolResult> {
const item = this.params.callHierarchyItem;
if (!item) {
const message = 'callHierarchyItem is required for incomingCalls.';
return { llmContent: message, returnDisplay: message };
}
const limit = this.params.limit ?? 20;
const serverName = this.params.serverName ?? item.serverName;
let calls: LspCallHierarchyIncomingCall[] = [];
try {
calls = await client.incomingCalls(item, serverName, limit);
} catch (error) {
const message = `LSP incoming calls failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!calls.length) {
const message = `No incoming calls found for ${this.describeCallHierarchyItemFull(
item,
)}.`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const slicedCalls = calls.slice(0, limit);
const lines = slicedCalls.map((call, index) => {
const targetItem = call.from;
const location = this.formatLocationWithServer(
{
uri: targetItem.uri,
range: targetItem.selectionRange,
serverName: targetItem.serverName,
},
workspaceRoot,
);
const kind = targetItem.kind ? ` (${targetItem.kind})` : '';
const detail = targetItem.detail ? ` ${targetItem.detail}` : '';
const rangeSuffix = this.formatCallRanges(call.fromRanges);
return `${index + 1}. ${targetItem.name}${kind}${detail} - ${location}${rangeSuffix}`;
});
const heading = `Incoming calls for ${this.describeCallHierarchyItemFull(
item,
)}:`;
const jsonSection = this.formatJsonSection(
'Incoming calls (JSON)',
slicedCalls,
);
return {
llmContent: [heading, ...lines].join('\n') + jsonSection,
returnDisplay: lines.join('\n'),
};
}
private async executeOutgoingCalls(client: LspClient): Promise<ToolResult> {
const item = this.params.callHierarchyItem;
if (!item) {
const message = 'callHierarchyItem is required for outgoingCalls.';
return { llmContent: message, returnDisplay: message };
}
const limit = this.params.limit ?? 20;
const serverName = this.params.serverName ?? item.serverName;
let calls: LspCallHierarchyOutgoingCall[] = [];
try {
calls = await client.outgoingCalls(item, serverName, limit);
} catch (error) {
const message = `LSP outgoing calls failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!calls.length) {
const message = `No outgoing calls found for ${this.describeCallHierarchyItemFull(
item,
)}.`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const slicedCalls = calls.slice(0, limit);
const lines = slicedCalls.map((call, index) => {
const targetItem = call.to;
const location = this.formatLocationWithServer(
{
uri: targetItem.uri,
range: targetItem.selectionRange,
serverName: targetItem.serverName,
},
workspaceRoot,
);
const kind = targetItem.kind ? ` (${targetItem.kind})` : '';
const detail = targetItem.detail ? ` ${targetItem.detail}` : '';
const rangeSuffix = this.formatCallRanges(call.fromRanges);
return `${index + 1}. ${targetItem.name}${kind}${detail} - ${location}${rangeSuffix}`;
});
const heading = `Outgoing calls for ${this.describeCallHierarchyItemFull(
item,
)}:`;
const jsonSection = this.formatJsonSection(
'Outgoing calls (JSON)',
slicedCalls,
);
return {
llmContent: [heading, ...lines].join('\n') + jsonSection,
returnDisplay: lines.join('\n'),
};
}
private resolveLocationTarget(): ResolvedTarget {
const filePath = this.params.filePath;
if (!filePath) {
return {
error: 'filePath is required for this operation.',
};
}
if (typeof this.params.line !== 'number') {
return {
error: 'line is required for this operation.',
};
}
const workspaceRoot = this.config.getProjectRoot();
const uri = this.resolveUri(filePath, workspaceRoot);
if (!uri) {
return {
error: 'A valid filePath is required when specifying a line/character.',
};
}
const position = {
line: Math.max(0, Math.floor(this.params.line - 1)),
character: Math.max(0, Math.floor((this.params.character ?? 1) - 1)),
};
const location: LspLocation = {
uri,
range: { start: position, end: position },
};
const description = this.formatLocationWithServer(
{ ...location, serverName: this.params.serverName },
workspaceRoot,
);
return {
location,
description,
};
}
private resolveUri(filePath: string, workspaceRoot: string): string | null {
if (!filePath) {
return null;
}
if (filePath.startsWith('file://') || filePath.includes('://')) {
return filePath;
}
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(workspaceRoot, filePath);
return pathToFileURL(absolutePath).toString();
}
private formatLocationWithServer(
location: LspLocation & { serverName?: string },
workspaceRoot: string,
): string {
const start = location.range.start;
let filePath = location.uri;
if (filePath.startsWith('file://')) {
filePath = fileURLToPath(filePath);
filePath = path.relative(workspaceRoot, filePath) || '.';
}
const serverSuffix =
location.serverName && location.serverName !== ''
? ` [${location.serverName}]`
: '';
return `${filePath}:${(start.line ?? 0) + 1}:${(start.character ?? 0) + 1}${serverSuffix}`;
}
private formatLocationWithoutServer(
location: LspLocation,
workspaceRoot: string,
): string {
const { uri, range } = location;
let filePath = uri;
if (uri.startsWith('file://')) {
filePath = fileURLToPath(uri);
filePath = path.relative(workspaceRoot, filePath) || '.';
}
const line = (range.start.line ?? 0) + 1;
const character = (range.start.character ?? 0) + 1;
return `${filePath}:${line}:${character}`;
}
private formatCallHierarchyItemLine(
item: LspCallHierarchyItem,
index: number,
workspaceRoot: string,
): string {
const location = this.formatLocationWithServer(
{
uri: item.uri,
range: item.selectionRange,
serverName: item.serverName,
},
workspaceRoot,
);
const kind = item.kind ? ` (${item.kind})` : '';
const detail = item.detail ? ` ${item.detail}` : '';
return `${index + 1}. ${item.name}${kind}${detail} - ${location}`;
}
private formatCallRanges(ranges: LspRange[]): string {
if (!ranges.length) {
return '';
}
const formatted = ranges.map((range) => this.formatPosition(range.start));
const maxShown = 3;
const shown = formatted.slice(0, maxShown);
const extra =
formatted.length > maxShown
? `, +${formatted.length - maxShown} more`
: '';
return ` (calls at ${shown.join(', ')}${extra})`;
}
private formatPosition(position: LspRange['start']): string {
return `${(position.line ?? 0) + 1}:${(position.character ?? 0) + 1}`;
}
private formatUriForDisplay(uri: string, workspaceRoot: string): string {
let filePath = uri;
if (uri.startsWith('file://')) {
filePath = fileURLToPath(uri);
}
if (path.isAbsolute(filePath)) {
return path.relative(workspaceRoot, filePath) || '.';
}
return filePath;
}
private formatJsonSection(label: string, data: unknown): string {
return `\n\n${label}:\n${JSON.stringify(data, null, 2)}`;
}
private describeCallHierarchyItemShort(): string {
const item = this.params.callHierarchyItem;
if (!item) {
return 'call hierarchy item';
}
return item.name || 'call hierarchy item';
}
private describeCallHierarchyItemFull(item: LspCallHierarchyItem): string {
const workspaceRoot = this.config.getProjectRoot();
const location = this.formatLocationWithServer(
{
uri: item.uri,
range: item.selectionRange,
serverName: item.serverName,
},
workspaceRoot,
);
return `${item.name} at ${location}`;
}
private getOperationLabel(): string {
switch (this.params.operation) {
case 'goToDefinition':
return 'go-to-definition';
case 'findReferences':
return 'find-references';
case 'hover':
return 'hover';
case 'documentSymbol':
return 'document symbols';
case 'workspaceSymbol':
return 'workspace symbol search';
case 'goToImplementation':
return 'go-to-implementation';
case 'prepareCallHierarchy':
return 'prepare call hierarchy';
case 'incomingCalls':
return 'incoming calls';
case 'outgoingCalls':
return 'outgoing calls';
default:
return this.params.operation;
}
}
}
/**
* Unified LSP tool that supports multiple operations:
* - goToDefinition: Find where a symbol is defined
* - findReferences: Find all references to a symbol
* - hover: Get hover information (documentation, type info)
* - documentSymbol: Get all symbols in a document
* - workspaceSymbol: Search for symbols across the workspace
* - goToImplementation: Find implementations of an interface or abstract method
* - prepareCallHierarchy: Get call hierarchy item at a position
* - incomingCalls: Find all functions that call the given function
* - outgoingCalls: Find all functions called by the given function
*/
export class LspTool extends BaseDeclarativeTool<LspToolParams, ToolResult> {
static readonly Name = ToolNames.LSP;
constructor(private readonly config: Config) {
super(
LspTool.Name,
ToolDisplayNames.LSP,
'Unified LSP operations for definitions, references, hover, symbols, and call hierarchy.',
Kind.Other,
{
type: 'object',
properties: {
operation: {
type: 'string',
description: 'LSP operation to execute.',
enum: [
'goToDefinition',
'findReferences',
'hover',
'documentSymbol',
'workspaceSymbol',
'goToImplementation',
'prepareCallHierarchy',
'incomingCalls',
'outgoingCalls',
],
},
filePath: {
type: 'string',
description: 'File path (absolute or workspace-relative).',
},
line: {
type: 'number',
description: '1-based line number for the target location.',
},
character: {
type: 'number',
description:
'1-based character/column number for the target location.',
},
includeDeclaration: {
type: 'boolean',
description:
'Include the declaration itself when looking up references.',
},
query: {
type: 'string',
description: 'Symbol query for workspace symbol search.',
},
callHierarchyItem: {
$ref: '#/definitions/LspCallHierarchyItem',
description: 'Call hierarchy item for incoming/outgoing calls.',
},
serverName: {
type: 'string',
description: 'Optional LSP server name to target.',
},
limit: {
type: 'number',
description: 'Optional maximum number of results to return.',
},
},
required: ['operation'],
definitions: {
LspPosition: {
type: 'object',
properties: {
line: { type: 'number' },
character: { type: 'number' },
},
required: ['line', 'character'],
},
LspRange: {
type: 'object',
properties: {
start: { $ref: '#/definitions/LspPosition' },
end: { $ref: '#/definitions/LspPosition' },
},
required: ['start', 'end'],
},
LspCallHierarchyItem: {
type: 'object',
properties: {
name: { type: 'string' },
kind: { type: 'string' },
rawKind: { type: 'number' },
detail: { type: 'string' },
uri: { type: 'string' },
range: { $ref: '#/definitions/LspRange' },
selectionRange: { $ref: '#/definitions/LspRange' },
data: {},
serverName: { type: 'string' },
},
required: ['name', 'uri', 'range', 'selectionRange'],
},
},
},
false,
false,
);
}
protected override validateToolParamValues(
params: LspToolParams,
): string | null {
const operation = params.operation;
if (LOCATION_REQUIRED_OPERATIONS.has(operation)) {
if (!params.filePath || params.filePath.trim() === '') {
return `filePath is required for ${operation}.`;
}
if (typeof params.line !== 'number') {
return `line is required for ${operation}.`;
}
}
if (FILE_REQUIRED_OPERATIONS.has(operation)) {
if (!params.filePath || params.filePath.trim() === '') {
return `filePath is required for ${operation}.`;
}
}
if (QUERY_REQUIRED_OPERATIONS.has(operation)) {
if (!params.query || params.query.trim() === '') {
return `query is required for ${operation}.`;
}
}
if (ITEM_REQUIRED_OPERATIONS.has(operation)) {
if (!params.callHierarchyItem) {
return `callHierarchyItem is required for ${operation}.`;
}
}
if (params.line !== undefined && params.line < 1) {
return 'line must be a positive number.';
}
if (params.character !== undefined && params.character < 1) {
return 'character must be a positive number.';
}
if (params.limit !== undefined && params.limit <= 0) {
return 'limit must be a positive number.';
}
return null;
}
protected createInvocation(
params: LspToolParams,
): ToolInvocation<LspToolParams, ToolResult> {
return new LspToolInvocation(this.config, params);
}
}

View File

@@ -25,6 +25,11 @@ export const ToolNames = {
WEB_FETCH: 'web_fetch',
WEB_SEARCH: 'web_search',
LS: 'list_directory',
LSP_WORKSPACE_SYMBOL: 'lsp_workspace_symbol',
LSP_GO_TO_DEFINITION: 'lsp_go_to_definition',
LSP_FIND_REFERENCES: 'lsp_find_references',
/** Unified LSP tool supporting all LSP operations. */
LSP: 'lsp',
} as const;
/**
@@ -48,6 +53,11 @@ export const ToolDisplayNames = {
WEB_FETCH: 'WebFetch',
WEB_SEARCH: 'WebSearch',
LS: 'ListFiles',
LSP_WORKSPACE_SYMBOL: 'LspWorkspaceSymbol',
LSP_GO_TO_DEFINITION: 'LspGoToDefinition',
LSP_FIND_REFERENCES: 'LspFindReferences',
/** Unified LSP tool display name. */
LSP: 'Lsp',
} as const;
// Migration from old tool names to new tool names
@@ -56,6 +66,8 @@ export const ToolDisplayNames = {
export const ToolNamesMigration = {
search_file_content: ToolNames.GREP, // Legacy name from grep tool
replace: ToolNames.EDIT, // Legacy name from edit tool
go_to_definition: ToolNames.LSP_GO_TO_DEFINITION,
find_references: ToolNames.LSP_FIND_REFERENCES,
} as const;
// Migration from old tool display names to new tool display names

View File

@@ -0,0 +1,255 @@
# LSP 工具重构计划
## 背景
对比 Claude Code 的 LSP tool 定义和当前实现,发现以下关键差异:
### Claude Code 的设计(目标)
```json
{
"name": "LSP",
"operations": [
"goToDefinition",
"findReferences",
"hover",
"documentSymbol",
"workspaceSymbol",
"goToImplementation",
"prepareCallHierarchy",
"incomingCalls",
"outgoingCalls"
],
"required_params": ["operation", "filePath", "line", "character"]
}
```
### 当前实现
- **分散的 3 个工具**`lsp_go_to_definition`, `lsp_find_references`, `lsp_workspace_symbol`
- **支持 3 个操作**goToDefinition, findReferences, workspaceSymbol
- **缺少 6 个操作**hover, documentSymbol, goToImplementation, prepareCallHierarchy, incomingCalls, outgoingCalls
---
## 重构目标
1. **统一工具设计**:将 3 个分散的工具合并为 1 个统一的 `LSP` 工具
2. **扩展操作支持**:添加缺失的 6 个 LSP 操作
3. **简化参数设计**:统一使用 operation + filePath + line + character 方式
4. **保持向后兼容**:旧工具名称继续支持
---
## 实施步骤
### Step 1: 扩展类型定义
**文件**: `packages/core/src/lsp/types.ts`
新增类型:
```typescript
// Hover 结果
interface LspHoverResult {
contents: string | { language: string; value: string }[];
range?: LspRange;
}
// Call Hierarchy 类型
interface LspCallHierarchyItem {
name: string;
kind: number;
uri: string;
range: LspRange;
selectionRange: LspRange;
detail?: string;
data?: unknown;
serverName?: string;
}
interface LspCallHierarchyIncomingCall {
from: LspCallHierarchyItem;
fromRanges: LspRange[];
}
interface LspCallHierarchyOutgoingCall {
to: LspCallHierarchyItem;
fromRanges: LspRange[];
}
```
扩展 LspClient 接口:
```typescript
interface LspClient {
// 现有方法
workspaceSymbols(query, limit): Promise<LspSymbolInformation[]>;
definitions(location, serverName, limit): Promise<LspDefinition[]>;
references(
location,
serverName,
includeDeclaration,
limit,
): Promise<LspReference[]>;
// 新增方法
hover(location, serverName): Promise<LspHoverResult | null>;
documentSymbols(uri, serverName, limit): Promise<LspSymbolInformation[]>;
implementations(location, serverName, limit): Promise<LspDefinition[]>;
prepareCallHierarchy(location, serverName): Promise<LspCallHierarchyItem[]>;
incomingCalls(
item,
serverName,
limit,
): Promise<LspCallHierarchyIncomingCall[]>;
outgoingCalls(
item,
serverName,
limit,
): Promise<LspCallHierarchyOutgoingCall[]>;
}
```
### Step 2: 创建统一 LSP 工具
**新文件**: `packages/core/src/tools/lsp.ts`
参数设计(采用灵活的操作特定验证):
```typescript
interface LspToolParams {
operation: LspOperation; // 必填
filePath?: string; // 位置类操作必填
line?: number; // 精确位置操作必填 (1-based)
character?: number; // 可选 (1-based)
query?: string; // workspaceSymbol 必填
callHierarchyItem?: object; // incomingCalls/outgoingCalls 必填
serverName?: string; // 可选
limit?: number; // 可选
includeDeclaration?: boolean; // findReferences 可选
}
type LspOperation =
| 'goToDefinition'
| 'findReferences'
| 'hover'
| 'documentSymbol'
| 'workspaceSymbol'
| 'goToImplementation'
| 'prepareCallHierarchy'
| 'incomingCalls'
| 'outgoingCalls';
```
各操作参数要求:
| 操作 | filePath | line | character | query | callHierarchyItem |
|------|----------|------|-----------|-------|-------------------|
| goToDefinition | 必填 | 必填 | 可选 | - | - |
| findReferences | 必填 | 必填 | 可选 | - | - |
| hover | 必填 | 必填 | 可选 | - | - |
| documentSymbol | 必填 | - | - | - | - |
| workspaceSymbol | - | - | - | 必填 | - |
| goToImplementation | 必填 | 必填 | 可选 | - | - |
| prepareCallHierarchy | 必填 | 必填 | 可选 | - | - |
| incomingCalls | - | - | - | - | 必填 |
| outgoingCalls | - | - | - | - | 必填 |
### Step 3: 扩展 NativeLspService
**文件**: `packages/cli/src/services/lsp/NativeLspService.ts`
新增 6 个方法:
1. `hover()` - 调用 `textDocument/hover`
2. `documentSymbols()` - 调用 `textDocument/documentSymbol`
3. `implementations()` - 调用 `textDocument/implementation`
4. `prepareCallHierarchy()` - 调用 `textDocument/prepareCallHierarchy`
5. `incomingCalls()` - 调用 `callHierarchy/incomingCalls`
6. `outgoingCalls()` - 调用 `callHierarchy/outgoingCalls`
### Step 4: 更新工具名称映射
**文件**: `packages/core/src/tools/tool-names.ts`
```typescript
export const ToolNames = {
LSP: 'lsp', // 新增
// 保留旧名称(标记 deprecated
LSP_WORKSPACE_SYMBOL: 'lsp_workspace_symbol',
LSP_GO_TO_DEFINITION: 'lsp_go_to_definition',
LSP_FIND_REFERENCES: 'lsp_find_references',
} as const;
export const ToolNamesMigration = {
lsp_go_to_definition: ToolNames.LSP,
lsp_find_references: ToolNames.LSP,
lsp_workspace_symbol: ToolNames.LSP,
} as const;
```
### Step 5: 更新 Config 工具注册
**文件**: `packages/core/src/config/config.ts`
- 注册新的统一 `LspTool`
- 保留旧工具注册(向后兼容)
- 可通过配置选项禁用旧工具
### Step 6: 向后兼容处理
**文件**: 现有 3 个 LSP 工具文件
- 添加 `@deprecated` 标记
- 添加 deprecation warning 日志
- 可选:内部转发到新工具实现
---
## 关键文件列表
| 文件路径 | 操作 |
| --------------------------------------------------- | --------------------------- |
| `packages/core/src/lsp/types.ts` | 修改 - 扩展类型定义 |
| `packages/core/src/tools/lsp.ts` | 新建 - 统一 LSP 工具 |
| `packages/core/src/tools/tool-names.ts` | 修改 - 添加工具名称 |
| `packages/cli/src/services/lsp/NativeLspService.ts` | 修改 - 添加 6 个新方法 |
| `packages/core/src/config/config.ts` | 修改 - 注册新工具 |
| `packages/core/src/tools/lsp-*.ts` (3个) | 修改 - 添加 deprecated 标记 |
---
## 验证方式
1. **单元测试**
-`LspTool` 参数验证测试
- 各操作执行逻辑测试
- 向后兼容测试
2. **集成测试**
- TypeScript Language Server 测试所有 9 个操作
- Python LSP 测试
- 多服务器场景测试
3. **手动验证**
- 在 VS Code 中测试各操作
- 验证旧工具名称仍可使用
- 验证 deprecation warning 输出
---
## 风险与缓解
| 风险 | 缓解措施 |
| --------------------------- | -------------------------------------- |
| 部分 LSP 服务器不支持新操作 | 独立 try-catch返回清晰错误消息 |
| Call Hierarchy 两步流程复杂 | 文档说明使用方式,提供示例 |
| 向后兼容增加维护成本 | 设置明确弃用时间线,配置选项控制旧工具 |
---
## 后续优化建议
1. 考虑是否需要支持更多 LSP 操作(如 `textDocument/rename`, `textDocument/formatting`
2. 考虑添加 LSP 服务器能力查询,动态返回支持的操作列表
3. 考虑优化 TypeScript Server warm-up 逻辑,减少首次调用延迟

File diff suppressed because it is too large Load Diff

View File

@@ -1,221 +0,0 @@
# VSCode IDE Companion 测试覆盖总结
## 概述
本次测试任务为 `packages/vscode-ide-companion` 补充了完整的测试体系,以确保 VSCode 插件和 WebView 的核心功能正常工作。
### 测试执行结果
```
Test Files 9 passed | 6 failed* (15)
Tests 136 passed | 5 failed* (141)
```
> *注:失败的测试是预先存在的 mock 不完整问题,不影响核心功能测试覆盖。
> *E2E/UI 自动化测试未包含在此统计中。
---
## 测试文件清单
### 新增/完善的测试文件
| 文件路径 | 测试目标 | 关键覆盖场景 |
|---------|---------|-------------|
| `src/webview/WebViewContent.test.ts` | 防止 WebView 白屏 | HTML 生成、CSP 配置、脚本引用、XSS 防护 |
| `src/webview/PanelManager.test.ts` | 防止 Tab 无法打开 | Panel 创建、复用、显示、资源释放 |
| `src/diff-manager.test.ts` | 防止 Diff 无法显示 | Diff 创建、接受、取消、去重 |
| `src/webview/MessageHandler.test.ts` | 防止消息丢失 | 消息路由、会话管理、权限处理 |
| `src/commands/index.test.ts` | 防止命令失效 | 命令注册、openChat、showDiff、login |
| `src/webview/App.test.tsx` | 主应用渲染 | 初始渲染、认证状态、消息显示、加载状态 |
| `src/webview/hooks/useVSCode.test.ts` | VSCode API 通信 | API 获取、postMessage、状态持久化、单例模式 |
| `src/webview/hooks/message/useMessageHandling.test.ts` | 消息处理逻辑 | 消息添加、流式响应、思考过程、状态管理 |
### 新增 E2E/UI 自动化
| 文件路径 | 测试目标 | 关键覆盖场景 |
|---------|---------|-------------|
| `e2e/tests/webview-send-message.spec.ts` | Webview UI 回归 | 发送消息、输入交互 |
| `e2e/tests/webview-permission.spec.ts` | 权限弹窗 UI | 权限弹窗展示与响应 |
| `e2e-vscode/tests/open-chat.spec.ts` | VS Code 端到端 | 命令面板打开 Webview |
| `e2e-vscode/tests/permission-drawer.spec.ts` | VS Code 端到端 | Webview 权限弹窗 |
### 基础设施文件
| 文件路径 | 用途 |
|---------|-----|
| `vitest.config.ts` | 测试配置,支持 jsdom 环境和 vscode mock |
| `src/test-setup.ts` | 全局测试 setup初始化 VSCode API mock |
| `src/__mocks__/vscode.ts` | 完整的 VSCode API mock 实现 |
| `src/webview/test-utils/render.tsx` | WebView 组件测试渲染工具 |
| `src/webview/test-utils/mocks.ts` | 测试数据工厂函数 |
---
## 测试覆盖的核心功能
### 1. WebView 渲染保障
**测试文件**: `WebViewContent.test.ts`, `App.test.tsx`
**覆盖场景**:
- ✅ HTML 基本结构完整性 (DOCTYPE, html, head, body)
- ✅ React 挂载点 (#root) 存在
- ✅ CSP (Content-Security-Policy) 正确配置
- ✅ 脚本引用 (webview.js) 正确
- ✅ XSS 防护 (URI 转义)
- ✅ 字符编码 (UTF-8)
- ✅ 视口设置 (viewport meta)
**保障效果**: 防止 WebView 白屏、样式异常、安全漏洞
### 2. Panel/Tab 管理保障
**测试文件**: `PanelManager.test.ts`
**覆盖场景**:
- ✅ 首次创建 Panel
- ✅ Panel 复用(不重复创建)
- ✅ Panel 图标设置
- ✅ 启用脚本执行
- ✅ 保持上下文 (retainContextWhenHidden)
- ✅ 本地资源根目录配置
- ✅ Panel 显示 (reveal)
- ✅ 资源释放 (dispose)
- ✅ 错误处理graceful fallback
**保障效果**: 防止 Tab 无法打开、聊天状态丢失
### 3. Diff 编辑器保障
**测试文件**: `diff-manager.test.ts`
**覆盖场景**:
- ✅ Diff 视图创建
- ✅ Diff 可见上下文设置
- ✅ Diff 标题格式
- ✅ 去重(防止重复打开)
- ✅ 保持焦点在 WebView
- ✅ 接受/取消 Diff
- ✅ 关闭所有 Diff
- ✅ 按路径关闭 Diff
**保障效果**: 防止 Diff 无法显示、代码变更丢失
### 4. 消息通信保障
**测试文件**: `MessageHandler.test.ts`, `useMessageHandling.test.ts`
**覆盖场景**:
- ✅ 消息路由 (sendMessage, cancelStreaming, newSession, etc.)
- ✅ 会话 ID 管理
- ✅ 权限响应处理
- ✅ 登录处理
- ✅ 流式内容追加
- ✅ 错误处理
- ✅ 消息添加/清除
- ✅ 思考过程处理
- ✅ 等待响应状态
**保障效果**: 防止用户消息丢失、AI 响应中断
### 5. 命令注册保障
**测试文件**: `commands/index.test.ts`
**覆盖场景**:
- ✅ 所有命令正确注册
- ✅ openChat 命令(复用/新建 Provider
- ✅ showDiff 命令(路径解析、错误处理)
- ✅ openNewChatTab 命令
- ✅ login 命令
**保障效果**: 防止快捷键/命令面板功能失效
### 6. VSCode API 通信保障
**测试文件**: `useVSCode.test.ts`
**覆盖场景**:
- ✅ API 获取
- ✅ postMessage 消息发送
- ✅ getState/setState 状态持久化
- ✅ 单例模式acquireVsCodeApi 只调用一次)
- ✅ 开发环境 fallback
**保障效果**: 防止 WebView 与扩展通信失败
---
## 测试运行命令
```bash
# 运行所有测试
npm test
# 运行带覆盖率的测试
npm test -- --coverage
# 运行特定测试文件
npm test -- src/webview/App.test.tsx
# 监视模式
npm test -- --watch
# Webview UI 自动化Playwright harness
npm run test:e2e --workspace=packages/vscode-ide-companion
# VS Code 端到端 UI可选
npm run test:e2e:vscode --workspace=packages/vscode-ide-companion
# 全量测试(包含 VS Code E2E
npm run test:all:full --workspace=packages/vscode-ide-companion
```
---
## CI 集成
测试已配置为可与 GitHub Actions 集成。建议在以下场景触发测试:
1. **PR 提交时** - 确保变更不破坏现有功能
2. **发布前** - 作为质量门禁
3. **每日构建** - 发现回归问题
---
## 后续改进建议
### 短期(建议优先处理)
1. **修复失败的预存测试** - 完善 mock 以通过所有测试
2. **扩展 VS Code E2E** - 覆盖 diff accept/cancel、会话恢复等关键流程
### 中期
1. **提高覆盖率** - 目标 80%+ 代码覆盖
2. **性能测试** - 添加大量消息场景的性能基准
3. **可视化回归测试** - 截图对比检测 UI 变化
### 长期
1. **Playwright 集成** - 扩展 UI 自动化覆盖面与稳定性
2. **多平台测试** - Windows/macOS/Linux 覆盖
3. **Mock 服务器** - 模拟真实 AI 响应场景
---
## 结论
本次测试覆盖了 VSCode IDE Companion 插件的核心功能点,能够有效防止以下关键问题:
| 问题类型 | 对应测试 | 覆盖程度 |
|---------|---------|---------|
| WebView 白屏 | WebViewContent, App | ✅ 完整 |
| Tab 无法打开 | PanelManager | ✅ 完整 |
| Diff 无法显示 | diff-manager | ✅ 完整 |
| 消息丢失 | MessageHandler, useMessageHandling | ✅ 完整 |
| 命令失效 | commands/index | ✅ 完整 |
| VSCode 通信失败 | useVSCode | ✅ 完整 |
**总体评估**: 测试体系已能够为 PR 合并和版本发布提供基本的质量保障。

View File

@@ -1,216 +0,0 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
test as base,
expect,
_electron,
type ElectronApplication,
type Page,
type Frame,
} from '@playwright/test';
import { downloadAndUnzipVSCode } from '@vscode/test-electron';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const extensionPath = path.resolve(__dirname, '../..');
const workspacePath = path.resolve(__dirname, '../../test/fixtures/workspace');
const createTempDir = (suffix: string) =>
fs.mkdtempSync(path.join(os.tmpdir(), `qwen-code-vscode-${suffix}-`));
const withTimeout = async <T>(promise: Promise<T>, timeoutMs: number) =>
new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('timeout'));
}, timeoutMs);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
const resolveVSCodeExecutablePath = async (): Promise<string> => {
if (process.env.VSCODE_EXECUTABLE_PATH) {
return process.env.VSCODE_EXECUTABLE_PATH;
}
if (process.platform === 'darwin') {
const defaultPath =
'/Applications/Visual Studio Code.app/Contents/MacOS/Electron';
if (fs.existsSync(defaultPath)) {
return defaultPath;
}
}
return downloadAndUnzipVSCode();
};
const getCommandPaletteShortcut = () =>
process.platform === 'darwin' ? 'Meta+Shift+P' : 'Control+Shift+P';
const getQuickOpenShortcut = () =>
process.platform === 'darwin' ? 'Meta+P' : 'Control+P';
export const test = base.extend<{
electronApp: ElectronApplication;
page: Page;
}>({
electronApp: async ({}, use: (r: ElectronApplication) => Promise<void>) => {
const executablePath = await resolveVSCodeExecutablePath();
const userDataDir = createTempDir('user-data');
const extensionsDir = createTempDir('extensions');
const electronApp = await _electron.launch({
executablePath,
args: [
'--no-sandbox',
'--disable-gpu-sandbox',
'--disable-updates',
'--skip-welcome',
'--skip-release-notes',
`--extensionDevelopmentPath=${extensionPath}`,
`--user-data-dir=${userDataDir}`,
`--extensions-dir=${extensionsDir}`,
'--disable-workspace-trust',
'--new-window',
workspacePath,
],
});
await use(electronApp);
try {
await withTimeout(electronApp.evaluate(({ app }) => app.quit()), 3_000);
} catch {
// Ignore if the app is already closed or evaluate fails.
}
try {
await withTimeout(electronApp.context().close(), 5_000);
} catch {
// Ignore context close errors.
}
try {
await withTimeout(electronApp.close(), 10_000);
} catch {
try {
await withTimeout(electronApp.kill(), 5_000);
} catch {
const process = electronApp.process();
if (process && !process.killed) {
process.kill('SIGKILL');
}
}
}
},
page: async ({ electronApp }: { electronApp: ElectronApplication }, use: (r: Page) => Promise<void>) => {
const page = await electronApp.firstWindow();
await page.waitForLoadState('domcontentloaded');
await use(page);
await page.close().catch(() => undefined);
},
});
export { expect };
export const waitForWebviewReady = async (page: Page) => {
await page.waitForSelector('iframe.webview', {
state: 'visible',
timeout: 60_000,
});
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
for (const frame of page.frames()) {
if (frame === page.mainFrame()) {
continue;
}
const url = frame.url();
if (!url.startsWith('vscode-webview://')) {
continue;
}
try {
const hasRoot = await frame.evaluate(
() => Boolean(document.querySelector('#root')),
);
if (hasRoot) {
return frame;
}
} catch {
// Ignore detached/cross-origin frames during probing.
}
}
await page.waitForTimeout(500);
}
const frameUrls = page.frames().map((frame) => frame.url());
throw new Error(
`Qwen Code webview not ready. Frames: ${frameUrls.join(', ')}`,
);
};
export const runCommand = async (page: Page, command: string) => {
const input = page.locator('.quick-input-widget input');
await page.locator('.monaco-workbench').waitFor();
await page.click('.monaco-workbench');
await page.keyboard.press('Escape').catch(() => undefined);
const openInput = async (shortcut: string) => {
await page.keyboard.press(shortcut);
return input.waitFor({ state: 'visible', timeout: 2_000 }).then(
() => true,
() => false,
);
};
const commandRow = page
.locator('.quick-input-list .monaco-list-row', { hasText: command })
.first();
const tryCommand = async (shortcut: string, query: string) => {
const opened = await openInput(shortcut);
if (!opened) {
return false;
}
await input.fill(query);
const found = await commandRow
.waitFor({ state: 'visible', timeout: 2_000 })
.then(
() => true,
() => false,
);
if (found) {
await commandRow.click();
await input.waitFor({ state: 'hidden' }).catch(() => undefined);
return true;
}
await page.keyboard.press('Escape').catch(() => undefined);
return false;
};
for (let attempt = 0; attempt < 10; attempt += 1) {
if (await tryCommand(getQuickOpenShortcut(), `>${command}`)) {
return;
}
if (await tryCommand(getCommandPaletteShortcut(), command)) {
return;
}
if (await tryCommand('F1', command)) {
return;
}
await page.waitForTimeout(1_000);
}
throw new Error(`Command not available yet: ${command}`);
};
export const dispatchWebviewMessage = async (
webview: Frame,
payload: unknown,
) => {
await webview.evaluate((message: unknown) => {
window.dispatchEvent(new MessageEvent('message', { data: message }));
}, payload);
};

View File

@@ -1,52 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium, defineConfig } from '@playwright/test';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const chromiumExecutablePath = (() => {
const defaultPath = chromium.executablePath();
const candidates = [defaultPath];
if (defaultPath.includes('mac-x64')) {
candidates.push(defaultPath.replace('mac-x64', 'mac-arm64'));
}
const headlessCandidates = candidates.map((candidate) =>
candidate
.replace('/chromium-', '/chromium_headless_shell-')
.replace('/chrome-mac-x64/', '/chrome-headless-shell-mac-x64/')
.replace('/chrome-mac-arm64/', '/chrome-headless-shell-mac-arm64/')
.replace(
'/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
'/chrome-headless-shell',
)
.replace('/Chromium.app/Contents/MacOS/Chromium', '/chrome-headless-shell'),
);
for (const candidate of [...headlessCandidates, ...candidates]) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return undefined;
})();
const launchOptions = chromiumExecutablePath
? { executablePath: chromiumExecutablePath }
: {};
export default defineConfig({
testDir: path.resolve(__dirname, 'tests'),
timeout: 90_000,
expect: { timeout: 15_000 },
use: {
headless: true,
launchOptions,
viewport: { width: 1440, height: 900 },
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
retries: process.env.CI ? 1 : 0,
workers: 1,
});

View File

@@ -1,18 +0,0 @@
import {
test,
expect,
runCommand,
waitForWebviewReady,
} from '../fixtures/vscode-fixture.js';
test('opens Qwen Code webview via command palette', async ({
page,
}: {
page: import('@playwright/test').Page;
}) => {
await runCommand(page, 'Qwen Code: Open');
const webview = await waitForWebviewReady(page);
const input = webview.getByRole('textbox', { name: 'Message input' });
await expect(input).toBeVisible();
});

View File

@@ -1,43 +0,0 @@
import {
test,
expect,
runCommand,
dispatchWebviewMessage,
waitForWebviewReady,
} from '../fixtures/vscode-fixture.js';
test('shows permission drawer and closes after allow', async ({
page,
}: {
page: import('@playwright/test').Page;
}) => {
await runCommand(page, 'Qwen Code: Open');
const webview = await waitForWebviewReady(page);
await dispatchWebviewMessage(webview, {
type: 'authState',
data: { authenticated: true },
});
await dispatchWebviewMessage(webview, {
type: 'permissionRequest',
data: {
options: [
{ name: 'Allow once', kind: 'allow_once', optionId: 'allow_once' },
{ name: 'Reject', kind: 'reject', optionId: 'reject' },
],
toolCall: {
toolCallId: 'tc-1',
title: 'Edit file',
kind: 'edit',
locations: [{ path: '/repo/src/file.ts' }],
status: 'pending',
},
},
});
const allowButton = webview.getByRole('button', { name: 'Allow once' });
await expect(allowButton).toBeVisible();
await allowButton.click();
await expect(allowButton).toBeHidden();
});

View File

@@ -1,36 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Qwen Code Webview Harness</title>
<style>
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
#root {
height: 100%;
}
</style>
</head>
<body data-extension-uri="https://example.com/">
<div id="root"></div>
<script>
window.__postedMessages = [];
window.__vscodeState = {};
window.__EXTENSION_URI__ =
document.body.getAttribute('data-extension-uri') || '';
window.acquireVsCodeApi = () => ({
postMessage: (message) => window.__postedMessages.push(message),
getState: () => window.__vscodeState,
setState: (state) => {
window.__vscodeState = state;
},
});
</script>
<script src="../../dist/webview.js"></script>
</body>
</html>

View File

@@ -1,54 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { chromium, defineConfig } from '@playwright/test';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const fixturesPath = path.resolve(__dirname, 'fixtures');
const baseURL = pathToFileURL(`${fixturesPath}${path.sep}`).toString();
const chromiumExecutablePath = (() => {
const defaultPath = chromium.executablePath();
const candidates = [defaultPath];
if (defaultPath.includes('mac-x64')) {
candidates.push(defaultPath.replace('mac-x64', 'mac-arm64'));
}
const headlessCandidates = candidates.map((candidate) =>
candidate
.replace('/chromium-', '/chromium_headless_shell-')
.replace('/chrome-mac-x64/', '/chrome-headless-shell-mac-x64/')
.replace('/chrome-mac-arm64/', '/chrome-headless-shell-mac-arm64/')
.replace(
'/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
'/chrome-headless-shell',
)
.replace('/Chromium.app/Contents/MacOS/Chromium', '/chrome-headless-shell'),
);
for (const candidate of [...headlessCandidates, ...candidates]) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return undefined;
})();
const launchOptions = chromiumExecutablePath
? { executablePath: chromiumExecutablePath }
: {};
export default defineConfig({
testDir: path.resolve(__dirname, 'tests'),
timeout: 60_000,
expect: { timeout: 10_000 },
use: {
baseURL,
headless: true,
launchOptions,
viewport: { width: 1280, height: 720 },
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
retries: process.env.CI ? 1 : 0,
});

View File

@@ -1,68 +0,0 @@
import { test, expect } from '@playwright/test';
import type { Page } from '@playwright/test';
declare global {
interface Window {
__postedMessages?: Array<{ type?: string; data?: unknown }>;
}
}
const sendWebviewMessage = async (page: Page, payload: unknown) => {
await page.evaluate((message: unknown) => {
window.dispatchEvent(new MessageEvent('message', { data: message }));
}, payload);
};
test.beforeEach(async ({ page }: { page: Page }) => {
await page.goto('webview-harness.html');
await page.waitForFunction(
() => document.querySelector('#root')?.children.length,
);
await page.waitForTimeout(50);
await sendWebviewMessage(page, {
type: 'authState',
data: { authenticated: true },
});
await expect(
page.getByRole('textbox', { name: 'Message input' }),
).toBeVisible();
});
test('permission drawer sends allow response', async ({ page }: { page: Page }) => {
await sendWebviewMessage(page, {
type: 'permissionRequest',
data: {
options: [
{ name: 'Allow once', kind: 'allow_once', optionId: 'allow_once' },
{ name: 'Reject', kind: 'reject', optionId: 'reject' },
],
toolCall: {
toolCallId: 'tc-1',
title: 'Edit file',
kind: 'edit',
locations: [{ path: '/repo/src/file.ts' }],
status: 'pending',
},
},
});
const allowButton = page.getByRole('button', { name: 'Allow once' });
await expect(allowButton).toBeVisible();
await allowButton.click();
await page.waitForFunction(
() =>
Array.isArray(window.__postedMessages) &&
window.__postedMessages.some((msg) => msg?.type === 'permissionResponse'),
);
const postedMessages = await page.evaluate(() => window.__postedMessages);
expect(postedMessages).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'permissionResponse',
data: { optionId: 'allow_once' },
}),
]),
);
});

View File

@@ -1,52 +0,0 @@
import { test, expect } from '@playwright/test';
import type { Page } from '@playwright/test';
declare global {
interface Window {
__postedMessages?: Array<{ type?: string; data?: unknown }>;
}
}
const sendWebviewMessage = async (page: Page, payload: unknown) => {
await page.evaluate((message: unknown) => {
window.dispatchEvent(new MessageEvent('message', { data: message }));
}, payload);
};
test.beforeEach(async ({ page }: { page: Page }) => {
await page.goto('webview-harness.html');
await page.waitForFunction(
() => document.querySelector('#root')?.children.length,
);
await page.waitForTimeout(50);
await sendWebviewMessage(page, {
type: 'authState',
data: { authenticated: true },
});
await expect(
page.getByRole('textbox', { name: 'Message input' }),
).toBeVisible();
});
test('sends a message when pressing Enter', async ({ page }: { page: Page }) => {
const input = page.getByRole('textbox', { name: 'Message input' });
await input.click();
await page.keyboard.type('Hello from Playwright');
await page.keyboard.press('Enter');
await page.waitForFunction(
() =>
Array.isArray(window.__postedMessages) &&
window.__postedMessages.some((msg) => msg?.type === 'sendMessage'),
);
const postedMessages = await page.evaluate(() => window.__postedMessages);
expect(postedMessages).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'sendMessage',
data: expect.objectContaining({ text: 'Hello from Playwright' }),
}),
]),
);
});

View File

@@ -10,6 +10,9 @@ import reactHooks from 'eslint-plugin-react-hooks';
import importPlugin from 'eslint-plugin-import';
export default [
{
files: ['**/*.ts', '**/*.tsx'],
},
{
files: ['**/*.js', '**/*.cjs', '**/*.mjs'],
languageOptions: {
@@ -25,89 +28,7 @@ export default [
},
},
},
// Default config for all TS files (general) - no React hooks rules
{
files: ['**/*.ts'],
plugins: {
'@typescript-eslint': typescriptEslint,
import: importPlugin,
},
languageOptions: {
parser: tsParser,
ecmaVersion: 2022,
sourceType: 'module',
},
rules: {
'@typescript-eslint/naming-convention': [
'warn',
{
selector: 'import',
format: ['camelCase', 'PascalCase'],
},
],
'react-hooks/rules-of-hooks': 'off', // Disable for all .ts files by default
'react-hooks/exhaustive-deps': 'off', // Disable for all .ts files by default
// Restrict deep imports but allow known-safe exceptions used by the webview
// - react-dom/client: required for React 18's createRoot API
// - ./styles/**: local CSS modules loaded by the webview
'import/no-internal-modules': [
'error',
{
allow: ['react-dom/client', './styles/**'],
},
],
curly: 'warn',
eqeqeq: 'warn',
'no-throw-literal': 'warn',
semi: 'warn',
},
},
// Specific config for test files (override above) - no React hooks rules
{
files: ['**/*{test,spec}.{ts,tsx}', '**/__tests__/**', '**/test/**', 'e2e/**', 'e2e-vscode/**'],
plugins: {
'@typescript-eslint': typescriptEslint,
import: importPlugin,
},
languageOptions: {
parser: tsParser,
ecmaVersion: 2022,
sourceType: 'module',
},
rules: {
'@typescript-eslint/naming-convention': [
'warn',
{
selector: 'import',
format: ['camelCase', 'PascalCase'],
},
],
'react-hooks/rules-of-hooks': 'off', // Explicitly disable for test files
'react-hooks/exhaustive-deps': 'off', // Explicitly disable for test files
// Restrict deep imports but allow known-safe exceptions used by the webview
// - react-dom/client: required for React 18's createRoot API
// - ./styles/**: local CSS modules loaded by the webview
'import/no-internal-modules': [
'error',
{
allow: ['react-dom/client', './styles/**'],
},
],
curly: 'warn',
eqeqeq: 'warn',
'no-throw-literal': 'warn',
semi: 'warn',
},
},
// JSX/TSX files in src - enable React hooks rules (most specific - should override others)
{
files: ['src/**/*.{tsx,jsx}'],
plugins: {
'@typescript-eslint': typescriptEslint,
'react-hooks': reactHooks,
@@ -133,54 +54,8 @@ export default [
format: ['camelCase', 'PascalCase'],
},
],
'react-hooks/rules-of-hooks': 'error', // Enable React hooks rule for JSX/TSX files in src
'react-hooks/exhaustive-deps': 'error', // Enable React hooks rule for JSX/TSX files in src
// Restrict deep imports but allow known-safe exceptions used by the webview
// - react-dom/client: required for React 18's createRoot API
// - ./styles/**: local CSS modules loaded by the webview
'import/no-internal-modules': [
'error',
{
allow: ['react-dom/client', './styles/**'],
},
],
curly: 'warn',
eqeqeq: 'warn',
'no-throw-literal': 'warn',
semi: 'warn',
},
},
// Special webview TS files that are used in React context - enable React hooks rules
{
files: ['src/webview/**/*.ts'],
plugins: {
'@typescript-eslint': typescriptEslint,
'react-hooks': reactHooks,
import: importPlugin,
},
languageOptions: {
parser: tsParser,
ecmaVersion: 2022,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
'@typescript-eslint/naming-convention': [
'warn',
{
selector: 'import',
format: ['camelCase', 'PascalCase'],
},
],
'react-hooks/rules-of-hooks': 'error', // Enable React hooks rule for webview .ts files
'react-hooks/exhaustive-deps': 'error', // Enable React hooks rule for webview .ts files
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
// Restrict deep imports but allow known-safe exceptions used by the webview
// - react-dom/client: required for React 18's createRoot API
// - ./styles/**: local CSS modules loaded by the webview

View File

@@ -127,17 +127,9 @@
"package": "vsce package --no-dependencies",
"test": "vitest run",
"test:ci": "vitest run --coverage",
"test:integration": "node ./test/runTest.cjs",
"test:e2e": "playwright test -c e2e/playwright.config.ts",
"test:e2e:vscode": "playwright test -c e2e-vscode/playwright.config.ts",
"test:all": "npm run test && npm run test:integration && npm run test:e2e",
"test:all:full": "npm run test:all && npm run test:e2e:vscode",
"validate:notices": "node ./scripts/validate-notices.js"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/markdown-it": "^14.1.2",
@@ -148,7 +140,6 @@
"@types/vscode": "^1.85.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^3.6.0",
"autoprefixer": "^10.4.22",
"esbuild": "^0.25.3",
@@ -161,13 +152,13 @@
"vitest": "^3.2.4"
},
"dependencies": {
"semver": "^7.7.2",
"@modelcontextprotocol/sdk": "^1.25.1",
"cors": "^2.8.5",
"express": "^5.1.0",
"markdown-it": "^14.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"semver": "^7.7.2",
"zod": "^3.25.76"
}
}

View File

@@ -1,350 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* VSCode API Mock
*
* 为测试环境提供完整的 VSCode API mock 实现。
* 这个文件通过 vitest.config.ts 中的 alias 配置被引用。
*/
import { vi } from 'vitest';
// Window API - 用于创建 UI 元素
export const window = {
showInformationMessage: vi.fn(),
showErrorMessage: vi.fn(),
showWarningMessage: vi.fn(),
createOutputChannel: vi.fn(() => ({
appendLine: vi.fn(),
show: vi.fn(),
dispose: vi.fn(),
})),
createWebviewPanel: vi.fn(),
createTerminal: vi.fn(() => ({
show: vi.fn(),
sendText: vi.fn(),
})),
onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })),
onDidChangeTextEditorSelection: vi.fn(() => ({ dispose: vi.fn() })),
activeTextEditor: undefined,
visibleTextEditors: [],
tabGroups: {
all: [],
activeTabGroup: { viewColumn: 1, tabs: [], isActive: true, activeTab: undefined },
close: vi.fn(),
},
showTextDocument: vi.fn(),
showWorkspaceFolderPick: vi.fn(),
registerWebviewPanelSerializer: vi.fn(() => ({ dispose: vi.fn() })),
withProgress: vi.fn(
(
_options: unknown,
callback: (progress: { report: () => void }) => unknown,
) => callback({ report: vi.fn() }),
),
};
// Workspace API - 用于访问工作区
export const workspace = {
workspaceFolders: [] as unknown[],
onDidCloseTextDocument: vi.fn(() => ({ dispose: vi.fn() })),
onDidDeleteFiles: vi.fn(() => ({ dispose: vi.fn() })),
onDidRenameFiles: vi.fn(() => ({ dispose: vi.fn() })),
onDidChangeWorkspaceFolders: vi.fn(() => ({ dispose: vi.fn() })),
onDidGrantWorkspaceTrust: vi.fn(() => ({ dispose: vi.fn() })),
registerTextDocumentContentProvider: vi.fn(() => ({ dispose: vi.fn() })),
registerFileSystemProvider: vi.fn(() => ({ dispose: vi.fn() })),
openTextDocument: vi.fn(),
isTrusted: true,
};
// Commands API - 用于注册和执行命令
export const commands = {
registerCommand: vi.fn(() => ({ dispose: vi.fn() })),
executeCommand: vi.fn(),
getCommands: vi.fn(() => Promise.resolve([])),
};
// URI 工具类
export const Uri = {
file: (path: string) => ({
fsPath: path,
scheme: 'file',
path,
authority: '',
query: '',
fragment: '',
toString: () => `file://${path}`,
toJSON: () => ({ scheme: 'file', path }),
with: vi.fn(),
}),
joinPath: vi.fn((base: { fsPath: string }, ...paths: string[]) => ({
fsPath: `${base.fsPath}/${paths.join('/')}`,
scheme: 'file',
path: `${base.fsPath}/${paths.join('/')}`,
toString: () => `file://${base.fsPath}/${paths.join('/')}`,
})),
from: vi.fn(
({
scheme,
path,
query,
}: {
scheme: string;
path: string;
query?: string;
}) => ({
scheme,
path,
fsPath: path,
authority: '',
query: query || '',
fragment: '',
toString: () => `${scheme}://${path}${query ? '?' + query : ''}`,
toJSON: () => ({ scheme, path, query }),
with: vi.fn(),
}),
),
parse: vi.fn((uri: string) => ({
scheme: 'file',
fsPath: uri.replace('file://', ''),
path: uri.replace('file://', ''),
authority: '',
query: '',
fragment: '',
toString: () => uri,
toJSON: () => ({ scheme: 'file', path: uri }),
with: vi.fn(),
})),
};
// 扩展相关
export const ExtensionMode = {
Development: 1,
Production: 2,
Test: 3,
};
// 事件发射器
export class EventEmitter<T = unknown> {
private listeners: Array<(e: T) => void> = [];
event = (listener: (e: T) => void) => {
this.listeners.push(listener);
return { dispose: () => this.listeners.splice(this.listeners.indexOf(listener), 1) };
};
fire = (data: T) => {
this.listeners.forEach((listener) => listener(data));
};
dispose = vi.fn();
}
// 扩展管理
export const extensions = {
getExtension: vi.fn(),
};
// ViewColumn 枚举
export const ViewColumn = {
One: 1,
Two: 2,
Three: 3,
Four: 4,
Five: 5,
Six: 6,
Seven: 7,
Eight: 8,
Nine: 9,
Active: -1,
Beside: -2,
};
// 进度位置
export const ProgressLocation = {
Notification: 15,
Window: 10,
SourceControl: 1,
};
// 文本编辑器选择变更类型
export const TextEditorSelectionChangeKind = {
Keyboard: 1,
Mouse: 2,
Command: 3,
};
// Disposable
export class Disposable {
static from(...disposables: Array<{ dispose: () => void }>) {
return {
dispose: () => disposables.forEach((d) => d.dispose()),
};
}
}
// Position
export class Position {
constructor(
public readonly line: number,
public readonly character: number,
) {}
isBefore(other: Position): boolean {
return (
this.line < other.line ||
(this.line === other.line && this.character < other.character)
);
}
isAfter(other: Position): boolean {
return (
this.line > other.line ||
(this.line === other.line && this.character > other.character)
);
}
}
// Range
export class Range {
constructor(
public readonly start: Position,
public readonly end: Position,
) {}
get isEmpty(): boolean {
return (
this.start.line === this.end.line &&
this.start.character === this.end.character
);
}
}
// Selection
export class Selection extends Range {
constructor(
public readonly anchor: Position,
public readonly active: Position,
) {
super(anchor, active);
}
}
// TextEdit
export class TextEdit {
static replace(range: Range, newText: string) {
return { range, newText };
}
static insert(position: Position, newText: string) {
return { range: new Range(position, position), newText };
}
static delete(range: Range) {
return { range, newText: '' };
}
}
// WorkspaceEdit
export class WorkspaceEdit {
private edits = new Map<string, TextEdit[]>();
replace(uri: { toString: () => string }, range: Range, newText: string) {
const key = uri.toString();
if (!this.edits.has(key)) {
this.edits.set(key, []);
}
this.edits.get(key)!.push(TextEdit.replace(range, newText));
}
insert(uri: { toString: () => string }, position: Position, newText: string) {
const key = uri.toString();
if (!this.edits.has(key)) {
this.edits.set(key, []);
}
this.edits.get(key)!.push(TextEdit.insert(position, newText));
}
delete(uri: { toString: () => string }, range: Range) {
const key = uri.toString();
if (!this.edits.has(key)) {
this.edits.set(key, []);
}
this.edits.get(key)!.push(TextEdit.delete(range));
}
}
// CancellationTokenSource
export class CancellationTokenSource {
token = {
isCancellationRequested: false,
onCancellationRequested: vi.fn(() => ({ dispose: vi.fn() })),
};
cancel() {
this.token.isCancellationRequested = true;
}
dispose() {}
}
// FileSystemError
export class FileSystemError extends Error {
static FileNotFound(uri?: { toString: () => string }) {
return new FileSystemError(`File not found: ${uri?.toString() || 'unknown'}`);
}
static FileExists(uri?: { toString: () => string }) {
return new FileSystemError(`File exists: ${uri?.toString() || 'unknown'}`);
}
static FileNotADirectory(uri?: { toString: () => string }) {
return new FileSystemError(`Not a directory: ${uri?.toString() || 'unknown'}`);
}
static FileIsADirectory(uri?: { toString: () => string }) {
return new FileSystemError(`Is a directory: ${uri?.toString() || 'unknown'}`);
}
static NoPermissions(uri?: { toString: () => string }) {
return new FileSystemError(`No permissions: ${uri?.toString() || 'unknown'}`);
}
static Unavailable(uri?: { toString: () => string }) {
return new FileSystemError(`Unavailable: ${uri?.toString() || 'unknown'}`);
}
}
// FileType
export const FileType = {
Unknown: 0,
File: 1,
Directory: 2,
SymbolicLink: 64,
};
// 默认导出所有 mock
export default {
window,
workspace,
commands,
Uri,
ExtensionMode,
EventEmitter,
extensions,
ViewColumn,
ProgressLocation,
TextEditorSelectionChangeKind,
Disposable,
Position,
Range,
Selection,
TextEdit,
WorkspaceEdit,
CancellationTokenSource,
FileSystemError,
FileType,
};

View File

@@ -1,518 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Commands 测试
*
* 测试目标:确保所有 VSCode 命令能正确注册和执行,防止命令失效
*
* 关键测试场景:
* 1. 命令注册 - 确保所有命令都正确注册到 VSCode
* 2. openChat - 确保能打开聊天面板
* 3. showDiff - 确保能显示 Diff 视图
* 4. openNewChatTab - 确保能打开新的聊天 Tab
* 5. login - 确保能触发登录流程
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as vscode from 'vscode';
import {
registerNewCommands,
openChatCommand,
showDiffCommand,
openNewChatTabCommand,
loginCommand,
} from './index.js';
import type { DiffManager } from '../diff-manager.js';
import type { WebViewProvider } from '../webview/WebViewProvider.js';
describe('Commands', () => {
let mockContext: vscode.ExtensionContext;
let mockLog: (message: string) => void;
let mockDiffManager: DiffManager;
let mockWebViewProviders: WebViewProvider[];
let mockGetWebViewProviders: () => WebViewProvider[];
let mockCreateWebViewProvider: () => WebViewProvider;
let registeredCommands: Map<string, (...args: unknown[]) => unknown>;
beforeEach(() => {
vi.clearAllMocks();
registeredCommands = new Map();
// Mock context
mockContext = {
subscriptions: [],
} as unknown as vscode.ExtensionContext;
// Mock logger
mockLog = vi.fn();
// Mock DiffManager
mockDiffManager = {
showDiff: vi.fn().mockResolvedValue(undefined),
} as unknown as DiffManager;
// Mock WebViewProviders
mockWebViewProviders = [];
mockGetWebViewProviders = () => mockWebViewProviders;
// Mock createWebViewProvider
const mockProvider = {
show: vi.fn().mockResolvedValue(undefined),
forceReLogin: vi.fn().mockResolvedValue(undefined),
} as unknown as WebViewProvider;
mockCreateWebViewProvider = vi.fn(() => mockProvider);
// Mock vscode.commands.registerCommand to capture handlers
vi.mocked(vscode.commands.registerCommand).mockImplementation(
(command: string, callback: (...args: unknown[]) => unknown) => {
registeredCommands.set(command, callback);
return { dispose: vi.fn() } as vscode.Disposable;
},
);
// Mock workspace folders
vi.mocked(vscode.workspace).workspaceFolders = [
{
uri: { fsPath: '/workspace' },
} as vscode.WorkspaceFolder,
];
vi.mocked(vscode.Uri.joinPath).mockImplementation(
(base: vscode.Uri, ...paths: string[]) =>
({
fsPath: `${base.fsPath}/${paths.join('/')}`,
}) as vscode.Uri,
);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('registerNewCommands', () => {
/**
* 测试:命令注册
*
* 验证 registerNewCommands 正确注册所有命令
* 如果命令未注册,用户将无法使用快捷键或命令面板执行操作
*/
it('should register all required commands', () => {
registerNewCommands(
mockContext,
mockLog,
mockDiffManager,
mockGetWebViewProviders,
mockCreateWebViewProvider,
);
expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
openChatCommand,
expect.any(Function),
);
expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
showDiffCommand,
expect.any(Function),
);
expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
openNewChatTabCommand,
expect.any(Function),
);
expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
loginCommand,
expect.any(Function),
);
});
/**
* 测试:订阅管理
*
* 验证命令 disposable 被添加到 context.subscriptions
* 确保扩展停用时能正确清理命令
*/
it('should add disposables to context.subscriptions', () => {
registerNewCommands(
mockContext,
mockLog,
mockDiffManager,
mockGetWebViewProviders,
mockCreateWebViewProvider,
);
// 应该注册 4 个命令,每个都添加到 subscriptions
expect(mockContext.subscriptions.length).toBe(4);
});
});
describe('openChat command', () => {
/**
* 测试:打开现有聊天面板
*
* 验证当已有 WebViewProvider 时,使用现有的 provider
* 防止创建不必要的新面板
*/
it('should show existing provider when providers exist', async () => {
const mockProvider = {
show: vi.fn().mockResolvedValue(undefined),
} as unknown as WebViewProvider;
mockWebViewProviders.push(mockProvider);
registerNewCommands(
mockContext,
mockLog,
mockDiffManager,
mockGetWebViewProviders,
mockCreateWebViewProvider,
);
const handler = registeredCommands.get(openChatCommand);
await handler?.();
expect(mockProvider.show).toHaveBeenCalled();
expect(mockCreateWebViewProvider).not.toHaveBeenCalled();
});
/**
* 测试:创建新聊天面板
*
* 验证当没有现有 provider 时,创建新的 provider
* 确保用户总能打开聊天界面
*/
it('should create new provider when no providers exist', async () => {
registerNewCommands(
mockContext,
mockLog,
mockDiffManager,
mockGetWebViewProviders,
mockCreateWebViewProvider,
);
const handler = registeredCommands.get(openChatCommand);
await handler?.();
expect(mockCreateWebViewProvider).toHaveBeenCalled();
});
/**
* 测试:使用最新的 provider
*
* 验证当有多个 provider 时,使用最后一个(最新的)
*/
it('should use the last provider when multiple exist', async () => {
const firstProvider = {
show: vi.fn().mockResolvedValue(undefined),
} as unknown as WebViewProvider;
const lastProvider = {
show: vi.fn().mockResolvedValue(undefined),
} as unknown as WebViewProvider;
mockWebViewProviders.push(firstProvider, lastProvider);
registerNewCommands(
mockContext,
mockLog,
mockDiffManager,
mockGetWebViewProviders,
mockCreateWebViewProvider,
);
const handler = registeredCommands.get(openChatCommand);
await handler?.();
expect(lastProvider.show).toHaveBeenCalled();
expect(firstProvider.show).not.toHaveBeenCalled();
});
});
describe('showDiff command', () => {
/**
* 测试:显示 Diff绝对路径
*
* 验证使用绝对路径时直接调用 diffManager
*/
it('should show diff with absolute path', async () => {
registerNewCommands(
mockContext,
mockLog,
mockDiffManager,
mockGetWebViewProviders,
mockCreateWebViewProvider,
);
const handler = registeredCommands.get(showDiffCommand);
await handler?.({
path: '/absolute/path/file.ts',
oldText: 'old content',
newText: 'new content',
});
expect(mockDiffManager.showDiff).toHaveBeenCalledWith(
'/absolute/path/file.ts',
'old content',
'new content',
);
});
/**
* 测试:显示 Diff相对路径
*
* 验证使用相对路径时正确拼接工作区路径
* 这是常见用法,确保相对路径能正确解析
*/
it('should resolve relative path against workspace folder', async () => {
registerNewCommands(
mockContext,
mockLog,
mockDiffManager,
mockGetWebViewProviders,
mockCreateWebViewProvider,
);
const handler = registeredCommands.get(showDiffCommand);
await handler?.({
path: 'src/file.ts',
oldText: 'old',
newText: 'new',
});
expect(mockDiffManager.showDiff).toHaveBeenCalledWith(
'/workspace/src/file.ts',
'old',
'new',
);
});
/**
* 测试:记录日志
*
* 验证 showDiff 命令记录日志
* 便于调试和问题排查
*/
it('should log the diff operation', async () => {
registerNewCommands(
mockContext,
mockLog,
mockDiffManager,
mockGetWebViewProviders,
mockCreateWebViewProvider,
);
const handler = registeredCommands.get(showDiffCommand);
await handler?.({
path: '/test/file.ts',
oldText: 'old',
newText: 'new',
});
expect(mockLog).toHaveBeenCalledWith(
expect.stringContaining('[Command] Showing diff'),
);
});
/**
* 测试:错误处理
*
* 验证 diffManager 错误被正确捕获和显示
* 防止未处理异常导致扩展崩溃
*/
it('should handle errors and show error message', async () => {
vi.mocked(mockDiffManager.showDiff).mockRejectedValue(
new Error('Diff error'),
);
registerNewCommands(
mockContext,
mockLog,
mockDiffManager,
mockGetWebViewProviders,
mockCreateWebViewProvider,
);
const handler = registeredCommands.get(showDiffCommand);
await handler?.({
path: '/test/file.ts',
oldText: 'old',
newText: 'new',
});
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
expect.stringContaining('Failed to show diff'),
);
expect(mockLog).toHaveBeenCalledWith(
expect.stringContaining('[Command] Error showing diff'),
);
});
/**
* 测试Windows 路径处理
*
* 验证 Windows 风格的绝对路径被正确识别
*/
it('should handle Windows absolute paths', async () => {
registerNewCommands(
mockContext,
mockLog,
mockDiffManager,
mockGetWebViewProviders,
mockCreateWebViewProvider,
);
const handler = registeredCommands.get(showDiffCommand);
await handler?.({
path: 'C:/Users/test/file.ts',
oldText: 'old',
newText: 'new',
});
// Windows 路径应该被识别为绝对路径,不进行拼接
expect(mockDiffManager.showDiff).toHaveBeenCalledWith(
'C:/Users/test/file.ts',
'old',
'new',
);
});
});
describe('openNewChatTab command', () => {
/**
* 测试:创建新聊天 Tab
*
* 验证命令总是创建新的 WebViewProvider
* 允许用户同时打开多个聊天会话
*/
it('should always create new provider', async () => {
registerNewCommands(
mockContext,
mockLog,
mockDiffManager,
mockGetWebViewProviders,
mockCreateWebViewProvider,
);
const handler = registeredCommands.get(openNewChatTabCommand);
await handler?.();
expect(mockCreateWebViewProvider).toHaveBeenCalled();
});
/**
* 测试:即使有现有 provider 也创建新的
*
* 与 openChat 不同openNewChatTab 总是创建新的
*/
it('should create new provider even when providers exist', async () => {
const existingProvider = {
show: vi.fn(),
} as unknown as WebViewProvider;
mockWebViewProviders.push(existingProvider);
registerNewCommands(
mockContext,
mockLog,
mockDiffManager,
mockGetWebViewProviders,
mockCreateWebViewProvider,
);
const handler = registeredCommands.get(openNewChatTabCommand);
await handler?.();
expect(mockCreateWebViewProvider).toHaveBeenCalled();
expect(existingProvider.show).not.toHaveBeenCalled();
});
});
describe('login command', () => {
/**
* 测试:登录已有 provider
*
* 验证有 provider 时调用 forceReLogin
*/
it('should call forceReLogin on existing provider', async () => {
const mockProvider = {
forceReLogin: vi.fn().mockResolvedValue(undefined),
} as unknown as WebViewProvider;
mockWebViewProviders.push(mockProvider);
registerNewCommands(
mockContext,
mockLog,
mockDiffManager,
mockGetWebViewProviders,
mockCreateWebViewProvider,
);
const handler = registeredCommands.get(loginCommand);
await handler?.();
expect(mockProvider.forceReLogin).toHaveBeenCalled();
});
/**
* 测试:无 provider 时提示用户
*
* 验证没有 provider 时显示提示信息
* 引导用户先打开聊天界面
*/
it('should show info message when no providers exist', async () => {
registerNewCommands(
mockContext,
mockLog,
mockDiffManager,
mockGetWebViewProviders,
mockCreateWebViewProvider,
);
const handler = registeredCommands.get(loginCommand);
await handler?.();
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
expect.stringContaining('Please open Qwen Code chat first'),
);
});
/**
* 测试:使用最新的 provider 进行登录
*
* 验证有多个 provider 时使用最后一个
*/
it('should use the last provider for login', async () => {
const firstProvider = {
forceReLogin: vi.fn().mockResolvedValue(undefined),
} as unknown as WebViewProvider;
const lastProvider = {
forceReLogin: vi.fn().mockResolvedValue(undefined),
} as unknown as WebViewProvider;
mockWebViewProviders.push(firstProvider, lastProvider);
registerNewCommands(
mockContext,
mockLog,
mockDiffManager,
mockGetWebViewProviders,
mockCreateWebViewProvider,
);
const handler = registeredCommands.get(loginCommand);
await handler?.();
expect(lastProvider.forceReLogin).toHaveBeenCalled();
expect(firstProvider.forceReLogin).not.toHaveBeenCalled();
});
});
describe('command constants', () => {
/**
* 测试:命令名称常量
*
* 验证命令名称常量正确定义
* 防止拼写错误导致命令无法找到
*/
it('should export correct command names', () => {
expect(openChatCommand).toBe('qwen-code.openChat');
expect(showDiffCommand).toBe('qwenCode.showDiff');
expect(openNewChatTabCommand).toBe('qwenCode.openNewChatTab');
expect(loginCommand).toBe('qwen-code.login');
});
});
});

View File

@@ -1,385 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* DiffManager 测试
*
* 测试目标:确保 Diff 编辑器能正确显示代码对比,防止 Diff 无法打开问题
*
* 关键测试场景:
* 1. Diff 显示 - 确保能正确打开 Diff 视图
* 2. Diff 接受 - 确保用户能接受代码更改
* 3. Diff 取消 - 确保用户能取消代码更改
* 4. 去重逻辑 - 防止重复打开相同的 Diff
* 5. 资源清理 - 确保 Diff 关闭后正确清理资源
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as vscode from 'vscode';
import { DiffManager, DiffContentProvider } from './diff-manager.js';
describe('DiffContentProvider', () => {
let provider: DiffContentProvider;
beforeEach(() => {
provider = new DiffContentProvider();
});
/**
* 测试:设置和获取内容
*
* 验证 DiffContentProvider 能正确存储和检索 Diff 内容
* 这是 VSCode Diff 视图的内容来源
*/
it('should set and get content', () => {
const uri = { toString: () => 'test-uri' } as vscode.Uri;
provider.setContent(uri, 'test content');
expect(provider.provideTextDocumentContent(uri)).toBe('test content');
});
/**
* 测试:未知 URI 返回空字符串
*
* 验证对于未设置内容的 URI 返回空字符串,而不是报错
*/
it('should return empty string for unknown URI', () => {
const uri = { toString: () => 'unknown-uri' } as vscode.Uri;
expect(provider.provideTextDocumentContent(uri)).toBe('');
});
/**
* 测试:删除内容
*
* 验证能正确删除已设置的内容
* 在 Diff 关闭时需要清理内容
*/
it('should delete content', () => {
const uri = { toString: () => 'test-uri' } as vscode.Uri;
provider.setContent(uri, 'test content');
provider.deleteContent(uri);
expect(provider.provideTextDocumentContent(uri)).toBe('');
});
/**
* 测试getContent 方法
*
* 验证 getContent 能返回原始内容或 undefined
*/
it('should return content via getContent', () => {
const uri = { toString: () => 'test-uri' } as vscode.Uri;
expect(provider.getContent(uri)).toBeUndefined();
provider.setContent(uri, 'test content');
expect(provider.getContent(uri)).toBe('test content');
});
});
describe('DiffManager', () => {
let diffManager: DiffManager;
let mockLog: (message: string) => void;
let mockContentProvider: DiffContentProvider;
beforeEach(() => {
vi.clearAllMocks();
mockLog = vi.fn();
mockContentProvider = new DiffContentProvider();
// 重置 vscode mocks
vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined);
vi.mocked(vscode.workspace.openTextDocument).mockResolvedValue({
getText: () => 'modified content',
} as vscode.TextDocument);
// Reset tabGroups to empty state
(vi.mocked(vscode.window.tabGroups).all as readonly vscode.TabGroup[]).length = 0;
diffManager = new DiffManager(mockLog, mockContentProvider);
});
afterEach(() => {
diffManager.dispose();
});
describe('showDiff', () => {
/**
* 测试:创建 Diff 视图
*
* 验证 showDiff 调用 vscode.diff 命令创建 Diff 视图
* 如果此功能失败,用户将无法看到代码对比
*/
it('should create diff view with correct URIs', async () => {
await diffManager.showDiff('/test/file.ts', 'old content', 'new content');
expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
'vscode.diff',
expect.any(Object), // left URI (old content)
expect.any(Object), // right URI (new content)
expect.stringContaining('file.ts'), // title contains filename
expect.any(Object), // options
);
});
/**
* 测试:设置 Diff 可见上下文
*
* 验证 showDiff 设置 qwen.diff.isVisible 上下文
* 这控制了接受/取消按钮的显示
*/
it('should set qwen.diff.isVisible context to true', async () => {
await diffManager.showDiff('/test/file.ts', 'old', 'new');
expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
'setContext',
'qwen.diff.isVisible',
true,
);
});
/**
* 测试Diff 标题格式
*
* 验证 Diff 视图的标题包含文件名和 "Before ↔ After"
* 帮助用户理解这是一个对比视图
*/
it('should use correct diff title format', async () => {
await diffManager.showDiff('/path/to/myfile.ts', 'old', 'new');
const diffCall = vi.mocked(vscode.commands.executeCommand).mock.calls.find(
(call) => call[0] === 'vscode.diff',
);
expect(diffCall?.[3]).toContain('myfile.ts');
expect(diffCall?.[3]).toContain('Before');
expect(diffCall?.[3]).toContain('After');
});
/**
* 测试:去重 - 相同内容不重复打开
*
* 验证对于相同的文件和内容,不会重复创建 Diff 视图
* 防止用户界面混乱
*/
it('should deduplicate rapid duplicate calls', async () => {
await diffManager.showDiff('/test/file.ts', 'old', 'new');
vi.mocked(vscode.commands.executeCommand).mockClear();
// 立即再次调用相同参数
await diffManager.showDiff('/test/file.ts', 'old', 'new');
// vscode.diff 不应该被再次调用
const diffCalls = vi.mocked(vscode.commands.executeCommand).mock.calls.filter(
(call) => call[0] === 'vscode.diff',
);
expect(diffCalls.length).toBe(0);
});
/**
* 测试:保持焦点在 WebView
*
* 验证打开 Diff 时设置 preserveFocus: true
* 确保聊天界面保持焦点,不打断用户输入
*/
it('should preserve focus when showing diff', async () => {
await diffManager.showDiff('/test/file.ts', 'old', 'new');
const diffCall = vi.mocked(vscode.commands.executeCommand).mock.calls.find(
(call) => call[0] === 'vscode.diff',
);
const options = diffCall?.[4] as { preserveFocus?: boolean } | undefined;
expect(options?.preserveFocus).toBe(true);
});
/**
* 测试:两参数重载 (自动读取原文件)
*
* 验证只传 newContent 时能自动读取原文件内容
*/
it('should support two-argument overload', async () => {
vi.mocked(vscode.workspace.openTextDocument).mockResolvedValue({
getText: () => 'original file content',
} as vscode.TextDocument);
await diffManager.showDiff('/test/file.ts', 'new content');
expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
'vscode.diff',
expect.any(Object),
expect.any(Object),
expect.any(String),
expect.any(Object),
);
});
});
describe('acceptDiff', () => {
/**
* 测试:接受 Diff 后清除上下文
*
* 验证接受 Diff 后设置 qwen.diff.isVisible 为 false
* 这会隐藏接受/取消按钮
*/
it('should set qwen.diff.isVisible context to false', async () => {
// 先显示 Diff
await diffManager.showDiff('/test/file.ts', 'old', 'new');
vi.mocked(vscode.commands.executeCommand).mockClear();
// 获取创建的 right URI
const uriFromCall = vi.mocked(vscode.Uri.from).mock.results.find(
(r) => (r.value as vscode.Uri).query?.includes('new'),
)?.value as vscode.Uri;
if (uriFromCall) {
await diffManager.acceptDiff(uriFromCall);
expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
'setContext',
'qwen.diff.isVisible',
false,
);
}
});
});
describe('cancelDiff', () => {
/**
* 测试:取消 Diff 后清除上下文
*
* 验证取消 Diff 后设置 qwen.diff.isVisible 为 false
*/
it('should set qwen.diff.isVisible context to false', async () => {
await diffManager.showDiff('/test/file.ts', 'old', 'new');
vi.mocked(vscode.commands.executeCommand).mockClear();
const uriFromCall = vi.mocked(vscode.Uri.from).mock.results.find(
(r) => (r.value as vscode.Uri).query?.includes('new'),
)?.value as vscode.Uri;
if (uriFromCall) {
await diffManager.cancelDiff(uriFromCall);
expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
'setContext',
'qwen.diff.isVisible',
false,
);
}
});
/**
* 测试:取消不存在的 Diff
*
* 验证取消不存在的 Diff 不会报错
*/
it('should handle canceling non-existent diff gracefully', async () => {
const unknownUri = {
toString: () => 'unknown-uri',
scheme: 'qwen-diff',
path: '/unknown/file.ts',
} as vscode.Uri;
await expect(diffManager.cancelDiff(unknownUri)).resolves.not.toThrow();
});
});
describe('closeAll', () => {
/**
* 测试:关闭所有 Diff
*
* 验证 closeAll 能关闭所有打开的 Diff 视图
* 在权限允许后需要清理 Diff
*/
it('should close all open diff editors', async () => {
await diffManager.showDiff('/test/file1.ts', 'old1', 'new1');
vi.mocked(vscode.commands.executeCommand).mockClear();
await diffManager.closeAll();
expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
'setContext',
'qwen.diff.isVisible',
false,
);
});
/**
* 测试:关闭空列表
*
* 验证在没有打开 Diff 时 closeAll 不会报错
*/
it('should not throw when no diffs are open', async () => {
await expect(diffManager.closeAll()).resolves.not.toThrow();
});
});
describe('closeDiff', () => {
/**
* 测试:按文件路径关闭 Diff
*
* 验证能通过文件路径关闭特定的 Diff 视图
*/
it('should close diff by file path', async () => {
await diffManager.showDiff('/test/file.ts', 'old', 'new');
const result = await diffManager.closeDiff('/test/file.ts');
// 应该返回关闭时的内容
expect(result).toBeDefined();
});
/**
* 测试:关闭不存在的文件 Diff
*
* 验证关闭不存在的文件 Diff 返回 undefined
*/
it('should return undefined for non-existent file', async () => {
const result = await diffManager.closeDiff('/non/existent.ts');
expect(result).toBeUndefined();
});
});
describe('suppressFor', () => {
/**
* 测试:临时抑制 Diff 显示
*
* 验证 suppressFor 能临时阻止 Diff 显示
* 用于在权限允许后短暂抑制新 Diff
*/
it('should suppress diffs for specified duration', () => {
// 这个方法设置一个内部时间戳
expect(() => diffManager.suppressFor(1000)).not.toThrow();
});
});
describe('dispose', () => {
/**
* 测试:资源释放
*
* 验证 dispose 不会报错
*/
it('should dispose without errors', () => {
expect(() => diffManager.dispose()).not.toThrow();
});
});
describe('onDidChange event', () => {
/**
* 测试:事件发射器
*
* 验证 DiffManager 有 onDidChange 事件
* 用于通知其他组件 Diff 状态变化
*/
it('should have onDidChange event', () => {
expect(diffManager.onDidChange).toBeDefined();
});
});
});

View File

@@ -1,36 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* 全局测试 setup 文件
* 提供 VSCode API 的全局 mock确保测试环境正确初始化
*
* 注意: VSCode API 的 mock 现在通过 vitest.config.ts 中的 alias 配置实现,
* 指向 src/__mocks__/vscode.ts
*/
import { vi, beforeEach, afterEach } from 'vitest';
/**
* Mock WebView API (window.acquireVsCodeApi)
*
* WebView 中的 React 组件通过 acquireVsCodeApi() 与扩展通信
* 这里提供 mock 实现用于组件测试
*/
export const mockVSCodeWebViewAPI = {
postMessage: vi.fn(),
getState: vi.fn(() => ({})),
setState: vi.fn(),
};
beforeEach(() => {
// 设置 WebView API mock
(globalThis as unknown as { acquireVsCodeApi: () => typeof mockVSCodeWebViewAPI }).acquireVsCodeApi =
() => mockVSCodeWebViewAPI;
});
afterEach(() => {
// 清理所有 mock 调用记录
vi.clearAllMocks();
});

View File

@@ -1,31 +0,0 @@
/**
* Type declarations for testing-library matchers
* This file adds type definitions for matchers like toBeInTheDocument
*/
import '@testing-library/jest-dom';
declare global {
namespace jest {
interface Matchers<R> {
toBeInTheDocument(): R;
toBeVisible(): R;
toBeEmptyDOMElement(): R;
toHaveTextContent(text: string | RegExp, options?: { normalizeWhitespace: boolean }): R;
toHaveAttribute(attr: string, value?: string): R;
toHaveClass(...classNames: string[]): R;
toHaveStyle(css: Record<string, unknown>): R;
toHaveFocus(): R;
toHaveFormValues(expectedValues: Record<string, unknown>): R;
toBeDisabled(): R;
toBeEnabled(): R;
toBeInvalid(): R;
toBeRequired(): R;
toBeValid(): R;
toContainElement(element: Element | null): R;
toContainHTML(htmlText: string): R;
}
}
}
export {};

View File

@@ -1,21 +0,0 @@
// Extend Jest's expect interface with Testing Library matchers
declare module "jest" {
interface Matchers<R> {
toBeInTheDocument(): R;
toBeVisible(): R;
toBeEmptyDOMElement(): R;
toHaveTextContent(text: string | RegExp, options?: { normalizeWhitespace: boolean }): R;
toHaveAttribute(attr: string, value?: string): R;
toHaveClass(...classNames: string[]): R;
toHaveStyle(css: Record<string, unknown>): R;
toHaveFocus(): R;
toHaveFormValues(expectedValues: Record<string, unknown>): R;
toBeDisabled(): R;
toBeEnabled(): R;
toBeInvalid(): R;
toBeRequired(): R;
toBeValid(): R;
toContainElement(element: Element | null): R;
toContainHTML(htmlText: string): R;
}
}

View File

@@ -1,54 +0,0 @@
/// <reference types="vitest" />
declare global {
namespace Vi {
interface Assertion {
/**
* Vitest-compatible version of testing-library matchers
* to resolve conflicts between @testing-library/jest-dom and vitest
*/
// Basic DOM matchers
toBeInTheDocument(): Vi.Assertion;
toBeVisible(): Vi.Assertion;
toBeEmptyDOMElement(): Vi.Assertion;
// Content matchers
toHaveTextContent(text: string | RegExp, options?: { normalizeWhitespace: boolean }): Vi.Assertion;
toHaveAttribute(name: string, value?: string): Vi.Assertion;
// Class and style matchers
toHaveClass(...classNames: string[]): Vi.Assertion;
toHaveStyle(css: Record<string, unknown>): Vi.Assertion;
// Form element matchers
toHaveFocus(): Vi.Assertion;
toHaveFormValues(expectedValues: Record<string, unknown>): Vi.Assertion;
toBeDisabled(): Vi.Assertion;
toBeEnabled(): Vi.Assertion;
toBeRequired(): Vi.Assertion;
toBeValid(): Vi.Assertion;
toBeInvalid(): Vi.Assertion;
// DOM structure matchers
toContainElement(element: Element | null): Vi.Assertion;
toContainHTML(html: string): Vi.Assertion;
toHaveAccessibleDescription(description?: string | RegExp): Vi.Assertion;
toHaveAccessibleName(name?: string | RegExp): Vi.Assertion;
// Value matchers
toHaveValue(value?: unknown): Vi.Assertion;
toHaveDisplayValue(value: string | RegExp | (string | RegExp)[]): Vi.Assertion;
// Event matchers
toBeChecked(): Vi.Assertion;
toBePartiallyChecked(): Vi.Assertion;
}
interface ExpectStatic {
// Add any additional expect matchers needed
}
}
}
// Export to make this an ES module
export {};

View File

@@ -1,597 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* App 组件测试
*
* 测试目标:确保 WebView 主应用能正确渲染和交互,防止 WebView 无法显示问题
*
* 关键测试场景:
* 1. 初始渲染 - 确保应用能正确渲染,不会白屏
* 2. 认证状态显示 - 根据认证状态显示正确的 UI
* 3. 加载状态 - 初始化时显示加载指示器
* 4. 消息显示 - 确保消息能正确渲染
* 5. 输入交互 - 确保用户能输入和发送消息
* 6. 权限弹窗 - 确保权限请求能正确显示和响应
* 7. 会话管理 - 确保会话切换功能正常
*/
/** @vitest-environment jsdom */
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { App } from './App.js';
// Mock all hooks that App depends on
vi.mock('./hooks/useVSCode.js', () => ({
useVSCode: () => ({
postMessage: vi.fn(),
getState: vi.fn(() => ({})),
setState: vi.fn(),
}),
}));
vi.mock('./hooks/session/useSessionManagement.js', () => ({
useSessionManagement: () => ({
currentSessionId: null,
currentSessionTitle: 'New Chat',
showSessionSelector: false,
setShowSessionSelector: vi.fn(),
filteredSessions: [],
sessionSearchQuery: '',
setSessionSearchQuery: vi.fn(),
handleSwitchSession: vi.fn(),
handleNewQwenSession: vi.fn(),
handleLoadQwenSessions: vi.fn(),
hasMore: false,
isLoading: false,
handleLoadMoreSessions: vi.fn(),
}),
}));
vi.mock('./hooks/file/useFileContext.js', () => ({
useFileContext: () => ({
activeFileName: null,
activeFilePath: null,
activeSelection: null,
workspaceFiles: [],
hasRequestedFiles: false,
requestWorkspaceFiles: vi.fn(),
addFileReference: vi.fn(),
focusActiveEditor: vi.fn(),
}),
}));
vi.mock('./hooks/message/useMessageHandling.js', () => ({
useMessageHandling: () => ({
messages: [],
isStreaming: false,
isWaitingForResponse: false,
loadingMessage: null,
addMessage: vi.fn(),
setMessages: vi.fn(),
clearMessages: vi.fn(),
startStreaming: vi.fn(),
appendStreamChunk: vi.fn(),
endStreaming: vi.fn(),
breakAssistantSegment: vi.fn(),
appendThinkingChunk: vi.fn(),
clearThinking: vi.fn(),
setWaitingForResponse: vi.fn(),
clearWaitingForResponse: vi.fn(),
}),
}));
vi.mock('./hooks/useToolCalls.js', () => ({
useToolCalls: () => ({
inProgressToolCalls: [],
completedToolCalls: [],
handleToolCallUpdate: vi.fn(),
clearToolCalls: vi.fn(),
}),
}));
vi.mock('./hooks/useWebViewMessages.js', () => ({
useWebViewMessages: vi.fn(),
}));
vi.mock('./hooks/useMessageSubmit.js', () => ({
useMessageSubmit: () => ({
handleSubmit: vi.fn((e: Event) => e.preventDefault()),
}),
}));
vi.mock('./hooks/useCompletionTrigger.js', () => ({
useCompletionTrigger: () => ({
isOpen: false,
items: [],
triggerChar: null,
query: '',
openCompletion: vi.fn(),
closeCompletion: vi.fn(),
refreshCompletion: vi.fn(),
}),
}));
// Mock CSS modules and styles
vi.mock('./styles/App.css', () => ({}));
vi.mock('./styles/messages.css', () => ({}));
describe('App', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset any module state
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Initial Rendering - 防止 WebView 白屏', () => {
/**
* 测试:基本渲染
*
* 验证 App 组件能成功渲染而不抛出错误
* 这是最基本的测试,如果失败意味着 WebView 将无法显示
*/
it('should render without crashing', () => {
expect(() => render(<App />)).not.toThrow();
});
/**
* 测试:聊天容器存在
*
* 验证主要的聊天容器 div 存在
* 这是所有 UI 元素的父容器
*/
it('should render chat container', () => {
const { container } = render(<App />);
const chatContainer = container.querySelector('.chat-container');
expect(chatContainer).toBeInTheDocument();
});
/**
* 测试:消息容器存在
*
* 验证消息列表容器存在
* 消息将在此容器中显示
*/
it('should render messages container', () => {
const { container } = render(<App />);
const messagesContainer = container.querySelector('.messages-container');
expect(messagesContainer).toBeInTheDocument();
});
});
describe('Loading State - 加载状态显示', () => {
/**
* 测试:初始加载状态
*
* 验证应用初始化时显示加载指示器
* 在认证状态确定前,用户应该看到加载提示
*/
it('should show loading state initially', () => {
render(<App />);
// 应该显示加载文本
expect(screen.getByText(/Preparing Qwen Code/i)).toBeInTheDocument();
});
});
describe('Authentication States - 认证状态显示', () => {
/**
* 测试:未认证状态 - 显示登录引导
*
* 验证用户未登录时显示 Onboarding 组件
* 引导用户进行登录
*/
it('should render correctly when not authenticated', async () => {
// 使用 useWebViewMessages mock 模拟认证状态变更
const { useWebViewMessages } = await import('./hooks/useWebViewMessages.js');
vi.mocked(useWebViewMessages).mockImplementation((props) => {
// 模拟收到未认证状态
React.useEffect(() => {
props.setIsAuthenticated?.(false);
}, [props]); // 添加 props 到依赖数组
});
render(<App />);
// 等待状态更新
await waitFor(() => {
// 未认证时应该显示登录相关 UI如 Onboarding
// 确保不会抛出错误
expect(document.body).toBeInTheDocument();
});
});
/**
* 测试:已认证状态 - 显示输入框
*
* 验证用户已登录时显示消息输入区域
*/
it('should show input form when authenticated', async () => {
const { useWebViewMessages } = await import('./hooks/useWebViewMessages.js');
vi.mocked(useWebViewMessages).mockImplementation((props) => {
React.useEffect(() => {
props.setIsAuthenticated?.(true);
}, [props]); // 添加 props 到依赖数组
});
render(<App />);
// 等待认证状态更新
await waitFor(() => {
// 已认证时应该有输入相关的 UI
expect(document.body).toBeInTheDocument();
});
});
});
describe('Message Rendering - 消息显示', () => {
/**
* 测试:用户消息显示
*
* 验证用户发送的消息能正确显示
*/
it('should render user messages correctly', async () => {
// Mock useMessageHandling to return messages
vi.doMock('./hooks/message/useMessageHandling.js', () => ({
useMessageHandling: () => ({
messages: [
{
role: 'user',
content: 'Hello, AI!',
timestamp: Date.now(),
},
],
isStreaming: false,
isWaitingForResponse: false,
loadingMessage: null,
addMessage: vi.fn(),
setMessages: vi.fn(),
clearMessages: vi.fn(),
startStreaming: vi.fn(),
appendStreamChunk: vi.fn(),
endStreaming: vi.fn(),
breakAssistantSegment: vi.fn(),
appendThinkingChunk: vi.fn(),
clearThinking: vi.fn(),
setWaitingForResponse: vi.fn(),
clearWaitingForResponse: vi.fn(),
}),
}));
// 由于 mock 限制,这里验证组件不崩溃
expect(() => render(<App />)).not.toThrow();
});
/**
* 测试AI 回复显示
*
* 验证 AI 的回复能正确显示
*/
it('should render assistant messages correctly', async () => {
vi.doMock('./hooks/message/useMessageHandling.js', () => ({
useMessageHandling: () => ({
messages: [
{
role: 'assistant',
content: 'Hello! How can I help you today?',
timestamp: Date.now(),
},
],
isStreaming: false,
isWaitingForResponse: false,
loadingMessage: null,
addMessage: vi.fn(),
setMessages: vi.fn(),
clearMessages: vi.fn(),
startStreaming: vi.fn(),
appendStreamChunk: vi.fn(),
endStreaming: vi.fn(),
breakAssistantSegment: vi.fn(),
appendThinkingChunk: vi.fn(),
clearThinking: vi.fn(),
setWaitingForResponse: vi.fn(),
clearWaitingForResponse: vi.fn(),
}),
}));
expect(() => render(<App />)).not.toThrow();
});
/**
* 测试:思考过程显示
*
* 验证 AI 的思考过程能正确显示
*/
it('should render thinking messages correctly', async () => {
vi.doMock('./hooks/message/useMessageHandling.js', () => ({
useMessageHandling: () => ({
messages: [
{
role: 'thinking',
content: 'Analyzing the code...',
timestamp: Date.now(),
},
],
isStreaming: false,
isWaitingForResponse: false,
loadingMessage: null,
addMessage: vi.fn(),
setMessages: vi.fn(),
clearMessages: vi.fn(),
startStreaming: vi.fn(),
appendStreamChunk: vi.fn(),
endStreaming: vi.fn(),
breakAssistantSegment: vi.fn(),
appendThinkingChunk: vi.fn(),
clearThinking: vi.fn(),
setWaitingForResponse: vi.fn(),
clearWaitingForResponse: vi.fn(),
}),
}));
expect(() => render(<App />)).not.toThrow();
});
});
describe('Empty State - 空状态显示', () => {
/**
* 测试:无消息时显示空状态
*
* 验证没有聊天记录时显示欢迎/空状态 UI
*/
it('should show empty state when no messages and authenticated', async () => {
const { useWebViewMessages } = await import('./hooks/useWebViewMessages.js');
vi.mocked(useWebViewMessages).mockImplementation((props) => {
React.useEffect(() => {
props.setIsAuthenticated?.(true);
}, [props]); // 添加 props 到依赖数组
});
const { container } = render(<App />);
// 等待状态更新
await waitFor(() => {
// 验证应用不会崩溃
expect(container.querySelector('.chat-container')).toBeInTheDocument();
});
});
});
describe('Streaming State - 流式响应状态', () => {
/**
* 测试:流式响应时的 UI 状态
*
* 验证 AI 正在生成回复时 UI 正确显示
*/
it('should handle streaming state correctly', async () => {
vi.doMock('./hooks/message/useMessageHandling.js', () => ({
useMessageHandling: () => ({
messages: [],
isStreaming: true,
isWaitingForResponse: false,
loadingMessage: null,
addMessage: vi.fn(),
setMessages: vi.fn(),
clearMessages: vi.fn(),
startStreaming: vi.fn(),
appendStreamChunk: vi.fn(),
endStreaming: vi.fn(),
breakAssistantSegment: vi.fn(),
appendThinkingChunk: vi.fn(),
clearThinking: vi.fn(),
setWaitingForResponse: vi.fn(),
clearWaitingForResponse: vi.fn(),
}),
}));
expect(() => render(<App />)).not.toThrow();
});
/**
* 测试:等待响应时的 UI 状态
*
* 验证等待 AI 响应时显示加载提示
*/
it('should show waiting message when waiting for response', async () => {
vi.doMock('./hooks/message/useMessageHandling.js', () => ({
useMessageHandling: () => ({
messages: [{ role: 'user', content: 'test', timestamp: Date.now() }],
isStreaming: false,
isWaitingForResponse: true,
loadingMessage: 'AI is thinking...',
addMessage: vi.fn(),
setMessages: vi.fn(),
clearMessages: vi.fn(),
startStreaming: vi.fn(),
appendStreamChunk: vi.fn(),
endStreaming: vi.fn(),
breakAssistantSegment: vi.fn(),
appendThinkingChunk: vi.fn(),
clearThinking: vi.fn(),
setWaitingForResponse: vi.fn(),
clearWaitingForResponse: vi.fn(),
}),
}));
expect(() => render(<App />)).not.toThrow();
});
});
describe('Session Management - 会话管理', () => {
/**
* 测试:会话标题显示
*
* 验证当前会话标题正确显示在 Header 中
*/
it('should display current session title in header', async () => {
vi.doMock('./hooks/session/useSessionManagement.js', () => ({
useSessionManagement: () => ({
currentSessionId: 'session-1',
currentSessionTitle: 'My Test Session',
showSessionSelector: false,
setShowSessionSelector: vi.fn(),
filteredSessions: [],
sessionSearchQuery: '',
setSessionSearchQuery: vi.fn(),
handleSwitchSession: vi.fn(),
handleNewQwenSession: vi.fn(),
handleLoadQwenSessions: vi.fn(),
hasMore: false,
isLoading: false,
handleLoadMoreSessions: vi.fn(),
}),
}));
expect(() => render(<App />)).not.toThrow();
});
});
describe('Tool Calls - 工具调用显示', () => {
/**
* 测试:进行中的工具调用
*
* 验证正在执行的工具调用能正确显示
*/
it('should render in-progress tool calls', async () => {
vi.doMock('./hooks/useToolCalls.js', () => ({
useToolCalls: () => ({
inProgressToolCalls: [
{
toolCallId: 'tc-1',
kind: 'read',
title: 'Reading file...',
status: 'pending',
timestamp: Date.now(),
},
],
completedToolCalls: [],
handleToolCallUpdate: vi.fn(),
clearToolCalls: vi.fn(),
}),
}));
expect(() => render(<App />)).not.toThrow();
});
/**
* 测试:已完成的工具调用
*
* 验证已完成的工具调用能正确显示
*/
it('should render completed tool calls', async () => {
vi.doMock('./hooks/useToolCalls.js', () => ({
useToolCalls: () => ({
inProgressToolCalls: [],
completedToolCalls: [
{
toolCallId: 'tc-1',
kind: 'read',
title: 'Read file.ts',
status: 'completed',
timestamp: Date.now(),
output: 'file content here',
},
],
handleToolCallUpdate: vi.fn(),
clearToolCalls: vi.fn(),
}),
}));
expect(() => render(<App />)).not.toThrow();
});
});
describe('Error Boundaries - 错误边界', () => {
/**
* 测试Hook 错误不会导致崩溃
*
* 验证即使某些 hook 抛出错误,整体应用也能优雅降级
*/
it('should not crash on hook errors', () => {
// 即使 mock 不完整,组件也应该能渲染
expect(() => render(<App />)).not.toThrow();
});
});
describe('Accessibility - 可访问性', () => {
/**
* 测试:基本可访问性结构
*
* 验证组件有正确的语义结构
*/
it('should have proper semantic structure', () => {
const { container } = render(<App />);
// 应该有容器 div
expect(container.querySelector('.chat-container')).toBeInTheDocument();
});
});
describe('CSS Classes - 样式类', () => {
/**
* 测试:关键 CSS 类存在
*
* 验证必要的 CSS 类被正确应用
* 如果缺失可能导致样式问题
*/
it('should have required CSS classes', () => {
const { container } = render(<App />);
// chat-container 是主容器的关键类
expect(container.querySelector('.chat-container')).toBeInTheDocument();
});
});
});
describe('App Integration - 集成场景', () => {
/**
* 测试:完整的消息发送流程(模拟)
*
* 验证从输入到发送的完整流程
* 这是用户最常用的功能
*/
it('should handle message submission flow', () => {
const { container } = render(<App />);
// 验证应用渲染成功
expect(container.querySelector('.chat-container')).toBeInTheDocument();
});
/**
* 测试:权限请求显示
*
* 验证当需要用户授权时,权限弹窗能正确显示
*/
it('should show permission drawer when permission requested', async () => {
// 权限请求通过 useWebViewMessages 触发
const { useWebViewMessages } = await import('./hooks/useWebViewMessages.js');
vi.mocked(useWebViewMessages).mockImplementation((props) => {
React.useEffect(() => {
props.setIsAuthenticated?.(true);
// 模拟权限请求
props.handlePermissionRequest({
options: [
{ optionId: 'allow', name: 'Allow', kind: 'allow' },
{ optionId: 'deny', name: 'Deny', kind: 'reject' },
],
toolCall: {
toolCallId: 'tc-1',
title: 'Edit file.ts',
kind: 'edit',
},
});
}, [props]); // 添加 props 到依赖数组
});
const { container } = render(<App />);
// 验证应用不崩溃
expect(container.querySelector('.chat-container')).toBeInTheDocument();
});
});

View File

@@ -1,337 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* MessageHandler 测试
*
* 测试目标:确保消息能正确在 Extension 和 WebView 之间路由,防止消息丢失
*
* 关键测试场景:
* 1. 消息路由 - 确保不同类型的消息路由到正确的处理器
* 2. 会话管理 - 确保会话 ID 能正确设置和获取
* 3. 权限处理 - 确保权限响应能正确传递
* 4. 流式内容 - 确保流式响应能正确追加
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MessageHandler } from './MessageHandler.js';
import type { QwenAgentManager } from '../services/qwenAgentManager.js';
import type { ConversationStore } from '../services/conversationStore.js';
describe('MessageHandler', () => {
let messageHandler: MessageHandler;
let mockAgentManager: QwenAgentManager;
let mockConversationStore: ConversationStore;
let mockSendToWebView: (message: unknown) => void;
beforeEach(() => {
// Mock QwenAgentManager - AI 代理管理器
mockAgentManager = {
sendMessage: vi.fn().mockResolvedValue(undefined),
createNewSession: vi.fn().mockResolvedValue({ id: 'new-session' }),
loadSession: vi.fn().mockResolvedValue([]),
switchToSession: vi.fn().mockResolvedValue(undefined),
cancelCurrentPrompt: vi.fn().mockResolvedValue(undefined),
connect: vi.fn().mockResolvedValue({ requiresAuth: false }),
disconnect: vi.fn(),
currentSessionId: null,
} as unknown as QwenAgentManager;
// Mock ConversationStore - 本地会话存储
mockConversationStore = {
createConversation: vi.fn().mockResolvedValue({ id: 'conv-1', messages: [] }),
getConversation: vi.fn().mockResolvedValue({ id: 'conv-1', messages: [] }),
updateConversation: vi.fn().mockResolvedValue(undefined),
deleteConversation: vi.fn().mockResolvedValue(undefined),
// 添加 addMessage 方法用于消息存储
addMessage: vi.fn().mockResolvedValue(undefined),
// 添加会话历史相关方法
getSessionHistory: vi.fn().mockResolvedValue([]),
saveSession: vi.fn().mockResolvedValue(undefined),
} as unknown as ConversationStore;
// Mock sendToWebView - 发送消息到 WebView
mockSendToWebView = vi.fn();
messageHandler = new MessageHandler(
mockAgentManager,
mockConversationStore,
null, // 初始会话 ID
mockSendToWebView,
);
});
describe('route', () => {
/**
* 测试:路由 sendMessage 消息
*
* 验证用户发送的消息能正确传递给 AI 代理
* 如果此功能失败,用户消息将无法发送
*/
it('should route sendMessage to agent manager', async () => {
await messageHandler.route({
type: 'sendMessage',
data: { content: 'Hello, AI!' },
});
expect(mockAgentManager.sendMessage).toHaveBeenCalled();
});
/**
* 测试:路由 cancelStreaming 消息
*
* 验证取消请求能正确传递给 AI 代理
* 用户点击停止按钮时需要此功能
*/
it('should route cancelStreaming to agent manager', async () => {
await messageHandler.route({
type: 'cancelStreaming',
data: {},
});
expect(mockAgentManager.cancelCurrentPrompt).toHaveBeenCalled();
});
/**
* 测试:路由 newSession 消息
*
* 验证新建会话请求能正确传递给 AI 代理
*/
it('should route newSession to agent manager', async () => {
await messageHandler.route({
type: 'newSession',
data: {},
});
expect(mockAgentManager.createNewSession).toHaveBeenCalled();
});
/**
* 测试:路由 loadSessions 消息
*
* 验证加载会话列表请求能正确处理
*/
it('should route loadSessions to agent manager', async () => {
await messageHandler.route({
type: 'loadSessions',
data: {},
});
expect(mockAgentManager.loadSession).toHaveBeenCalled();
});
/**
* 测试:路由 switchSession 消息
*
* 验证切换会话请求能正确传递给 AI 代理
*/
it('should route switchSession to agent manager', async () => {
await messageHandler.route({
type: 'switchSession',
data: { sessionId: 'session-123' },
});
expect(mockAgentManager.switchToSession).toHaveBeenCalled();
});
/**
* 测试:处理未知消息类型
*
* 验证未知消息类型不会导致崩溃
*/
it('should handle unknown message types gracefully', async () => {
await expect(
messageHandler.route({
type: 'unknownType',
data: {},
}),
).resolves.not.toThrow();
});
});
describe('setCurrentConversationId / getCurrentConversationId', () => {
/**
* 测试:设置和获取会话 ID
*
* 验证会话 ID 能正确设置和检索
* 这对于会话状态管理至关重要
*/
it('should set and get conversation ID', () => {
messageHandler.setCurrentConversationId('test-conversation-id');
expect(messageHandler.getCurrentConversationId()).toBe('test-conversation-id');
});
/**
* 测试:初始会话 ID 为 null
*
* 验证初始状态下会话 ID 为 null
*/
it('should return null initially', () => {
expect(messageHandler.getCurrentConversationId()).toBeNull();
});
/**
* 测试:设置 null 会话 ID
*
* 验证能将会话 ID 重置为 null
*/
it('should allow setting null', () => {
messageHandler.setCurrentConversationId('test-id');
messageHandler.setCurrentConversationId(null);
expect(messageHandler.getCurrentConversationId()).toBeNull();
});
});
describe('setPermissionHandler', () => {
/**
* 测试:设置权限处理器
*
* 验证权限处理器能正确设置
* 权限请求需要此处理器来响应用户选择
*/
it('should set permission handler', async () => {
const handler = vi.fn();
messageHandler.setPermissionHandler(handler);
// 触发权限响应
await messageHandler.route({
type: 'permissionResponse',
data: { optionId: 'allow_once' },
});
expect(handler).toHaveBeenCalledWith({
type: 'permissionResponse',
data: { optionId: 'allow_once' },
});
});
/**
* 测试:权限响应正确传递选项 ID
*
* 验证用户选择的权限选项能正确传递
*/
it('should pass correct optionId to handler', async () => {
const handler = vi.fn();
messageHandler.setPermissionHandler(handler);
await messageHandler.route({
type: 'permissionResponse',
data: { optionId: 'allow_always' },
});
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
data: { optionId: 'allow_always' },
}),
);
});
});
describe('setLoginHandler', () => {
/**
* 测试:设置登录处理器
*
* 验证登录处理器能正确设置
* 用户执行 /login 命令时需要此处理器
*/
it('should set login handler', async () => {
const loginHandler = vi.fn().mockResolvedValue(undefined);
messageHandler.setLoginHandler(loginHandler);
await messageHandler.route({
type: 'login',
data: {},
});
expect(loginHandler).toHaveBeenCalled();
});
});
describe('appendStreamContent', () => {
/**
* 测试:追加流式内容
*
* 验证流式响应内容能正确追加
* AI 回复是流式返回的,需要逐块追加
*/
it('should append stream content without error', () => {
expect(() => {
messageHandler.appendStreamContent('Hello');
messageHandler.appendStreamContent(' World');
}).not.toThrow();
});
});
describe('error handling', () => {
/**
* 测试:处理 sendMessage 错误
*
* 验证发送消息失败时不会导致崩溃
*/
it('should handle sendMessage errors gracefully', async () => {
vi.mocked(mockAgentManager.sendMessage).mockRejectedValue(
new Error('Network error'),
);
// 应该不抛出错误(错误应该被内部处理)
await expect(
messageHandler.route({
type: 'sendMessage',
data: { content: 'test' },
}),
).resolves.not.toThrow();
});
/**
* 测试:处理 loadSessions 错误
*
* 验证加载会话失败时不会导致崩溃
*/
it('should handle loadSessions errors gracefully', async () => {
vi.mocked(mockAgentManager.loadSession).mockRejectedValue(
new Error('Load failed'),
);
await expect(
messageHandler.route({
type: 'loadSessions',
data: {},
}),
).resolves.not.toThrow();
});
});
describe('message types coverage', () => {
/**
* 测试:支持的消息类型
*
* 验证所有关键消息类型都能被处理
*/
const messageTypes = [
'sendMessage',
'cancelStreaming',
'newSession',
'loadSessions',
'switchSession',
'permissionResponse',
'login',
'attachFile',
'openFile',
'setApprovalMode',
];
messageTypes.forEach((type) => {
it(`should handle "${type}" message type`, async () => {
await expect(
messageHandler.route({
type,
data: {},
}),
).resolves.not.toThrow();
});
});
});
});

View File

@@ -1,315 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* PanelManager 测试
*
* 测试目标:确保 WebView Panel/Tab 能正确创建和管理,防止 Tab 无法打开问题
*
* 关键测试场景:
* 1. Panel 创建 - 确保能成功创建 WebView Panel
* 2. Panel 复用 - 确保不会重复创建 Panel
* 3. Panel 显示 - 确保 Panel 能正确 reveal
* 4. Tab 捕获 - 确保能正确捕获和追踪 Tab
* 5. 资源释放 - 确保 dispose 正确清理资源
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as vscode from 'vscode';
import { PanelManager } from './PanelManager.js';
describe('PanelManager', () => {
let panelManager: PanelManager;
let mockExtensionUri: vscode.Uri;
let onDisposeCallback: () => void;
let mockPanel: vscode.WebviewPanel;
beforeEach(() => {
vi.clearAllMocks();
mockExtensionUri = { fsPath: '/path/to/extension' } as vscode.Uri;
onDisposeCallback = vi.fn();
// 创建 mock panel
mockPanel = {
webview: {
html: '',
options: {},
onDidReceiveMessage: vi.fn(() => ({ dispose: vi.fn() })),
postMessage: vi.fn(),
},
viewType: 'qwenCode.chat',
title: 'Qwen Code',
iconPath: null,
visible: true,
active: true,
viewColumn: 1,
onDidDispose: vi.fn(() => ({ dispose: vi.fn() })),
onDidChangeViewState: vi.fn(() => ({ dispose: vi.fn() })),
reveal: vi.fn(),
dispose: vi.fn(),
} as unknown as vscode.WebviewPanel;
// Mock vscode.window.createWebviewPanel
vi.mocked(vscode.window.createWebviewPanel).mockReturnValue(mockPanel);
vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined);
// Mock tabGroups
(vi.mocked(vscode.window.tabGroups).all as readonly vscode.TabGroup[]).length = 0;
Object.assign(vi.mocked(vscode.window.tabGroups).activeTabGroup, {
viewColumn: 1,
tabs: [],
isActive: true,
activeTab: undefined,
});
panelManager = new PanelManager(mockExtensionUri, onDisposeCallback);
});
afterEach(() => {
panelManager.dispose();
});
describe('createPanel', () => {
/**
* 测试:首次创建 Panel
*
* 验证 PanelManager 能成功创建新的 WebView Panel
* 如果创建失败,用户将看不到聊天界面
*/
it('should create a new panel when none exists', async () => {
const result = await panelManager.createPanel();
expect(result).toBe(true);
expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith(
'qwenCode.chat', // viewType
'Qwen Code', // title
expect.any(Object), // viewColumn options
expect.objectContaining({
enableScripts: true, // 必须启用脚本才能运行 React
retainContextWhenHidden: true, // 隐藏时保持状态
}),
);
});
/**
* 测试Panel 复用
*
* 验证当 Panel 已存在时,不会重复创建
* 防止创建多个不必要的 Panel
*/
it('should return false if panel already exists', async () => {
await panelManager.createPanel();
vi.mocked(vscode.window.createWebviewPanel).mockClear();
const result = await panelManager.createPanel();
expect(result).toBe(false);
expect(vscode.window.createWebviewPanel).not.toHaveBeenCalled();
});
/**
* 测试Panel 图标设置
*
* 验证创建 Panel 时设置了正确的图标
* 图标显示在 Tab 上,帮助用户识别
*/
it('should set panel icon', async () => {
await panelManager.createPanel();
expect(mockPanel.iconPath).toBeDefined();
});
/**
* 测试:启用脚本
*
* 验证创建 Panel 时启用了脚本执行
* 这是 React 应用运行的必要条件
*/
it('should create panel with scripts enabled', async () => {
await panelManager.createPanel();
const createCall = vi.mocked(vscode.window.createWebviewPanel).mock.calls[0];
const options = createCall[3] as vscode.WebviewPanelOptions & vscode.WebviewOptions;
expect(options.enableScripts).toBe(true);
});
/**
* 测试:保持上下文
*
* 验证创建 Panel 时设置了 retainContextWhenHidden
* 防止切换 Tab 时丢失聊天状态
*/
it('should retain context when hidden', async () => {
await panelManager.createPanel();
const createCall = vi.mocked(vscode.window.createWebviewPanel).mock.calls[0];
const options = createCall[3] as vscode.WebviewPanelOptions & vscode.WebviewOptions;
expect(options.retainContextWhenHidden).toBe(true);
});
/**
* 测试:本地资源根目录
*
* 验证创建 Panel 时设置了正确的本地资源根目录
* 这决定了 WebView 能访问哪些本地文件
*/
it('should set local resource roots', async () => {
await panelManager.createPanel();
const createCall = vi.mocked(vscode.window.createWebviewPanel).mock.calls[0];
const options = createCall[3] as vscode.WebviewPanelOptions & vscode.WebviewOptions;
expect(options.localResourceRoots).toBeDefined();
expect(options.localResourceRoots?.length).toBeGreaterThan(0);
});
});
describe('getPanel', () => {
/**
* 测试:获取空 Panel
*
* 验证在没有创建 Panel 时返回 null
*/
it('should return null when no panel exists', () => {
expect(panelManager.getPanel()).toBeNull();
});
/**
* 测试:获取已创建的 Panel
*
* 验证能正确获取已创建的 Panel 实例
*/
it('should return panel after creation', async () => {
await panelManager.createPanel();
expect(panelManager.getPanel()).toBe(mockPanel);
});
});
describe('setPanel', () => {
/**
* 测试:设置 Panel用于恢复
*
* 验证能设置已有的 Panel用于 VSCode 重启后的恢复
*/
it('should set panel for restoration', () => {
panelManager.setPanel(mockPanel);
expect(panelManager.getPanel()).toBe(mockPanel);
});
});
describe('revealPanel', () => {
/**
* 测试:显示 Panel
*
* 验证能正确调用 reveal 显示 Panel
* 当用户点击打开聊天时需要此功能
*/
it('should reveal panel when it exists', async () => {
await panelManager.createPanel();
panelManager.revealPanel();
expect(mockPanel.reveal).toHaveBeenCalled();
});
/**
* 测试:保持焦点选项
*
* 验证 reveal 时能正确传递 preserveFocus 参数
*/
it('should respect preserveFocus parameter', async () => {
await panelManager.createPanel();
panelManager.revealPanel(true);
expect(mockPanel.reveal).toHaveBeenCalledWith(
expect.any(Number),
true, // preserveFocus
);
});
});
describe('dispose', () => {
/**
* 测试:释放资源
*
* 验证 dispose 正确清理 Panel 资源
* 防止内存泄漏
*/
it('should dispose panel and set to null', async () => {
await panelManager.createPanel();
panelManager.dispose();
expect(mockPanel.dispose).toHaveBeenCalled();
expect(panelManager.getPanel()).toBeNull();
});
/**
* 测试:安全 dispose
*
* 验证在没有 Panel 时 dispose 不会报错
*/
it('should not throw when disposing without panel', () => {
expect(() => panelManager.dispose()).not.toThrow();
});
});
describe('registerDisposeHandler', () => {
/**
* 测试:注册 dispose 回调
*
* 验证能注册 Panel dispose 时的回调
* 用于清理相关资源
*/
it('should register dispose handler', async () => {
await panelManager.createPanel();
const disposables: vscode.Disposable[] = [];
panelManager.registerDisposeHandler(disposables);
expect(mockPanel.onDidDispose).toHaveBeenCalled();
});
});
describe('registerViewStateChangeHandler', () => {
/**
* 测试:注册视图状态变更处理器
*
* 验证能监听 Panel 的视图状态变更
* 用于更新 Tab 追踪
*/
it('should register view state change handler', async () => {
await panelManager.createPanel();
const disposables: vscode.Disposable[] = [];
panelManager.registerViewStateChangeHandler(disposables);
expect(mockPanel.onDidChangeViewState).toHaveBeenCalled();
});
});
describe('error handling', () => {
/**
* 测试:创建 Panel 失败处理
*
* 验证当创建新编辑器组失败时能正确 fallback
*/
it('should handle newGroupRight command failure gracefully', async () => {
vi.mocked(vscode.commands.executeCommand).mockRejectedValueOnce(
new Error('Command failed'),
);
// 应该不抛出错误,而是 fallback 到其他方式
const result = await panelManager.createPanel();
expect(result).toBe(true);
});
});
});

View File

@@ -1,165 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* WebViewContent 测试
*
* 测试目标:确保 WebView HTML 能正确生成,防止 WebView 白屏问题
*
* 关键测试场景:
* 1. HTML 结构完整性 - 确保生成的 HTML 包含必要元素
* 2. CSP 配置正确 - 防止安全问题
* 3. 脚本引用正确 - 确保 React 应用能加载
* 4. XSS 防护 - 确保 URI 被正确转义
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type * as vscode from 'vscode';
import { WebViewContent } from './WebViewContent.js';
describe('WebViewContent', () => {
let mockPanel: vscode.WebviewPanel;
let mockExtensionUri: vscode.Uri;
beforeEach(() => {
// 模拟扩展 URI
mockExtensionUri = { fsPath: '/path/to/extension' } as vscode.Uri;
// 模拟 WebView Panel
mockPanel = {
webview: {
asWebviewUri: vi.fn((uri: { fsPath: string }) => ({
toString: () => `vscode-webview://resource${uri.fsPath}`,
})),
cspSource: 'vscode-webview:',
},
} as unknown as vscode.WebviewPanel;
});
/**
* 测试HTML 基本结构
*
* 验证生成的 HTML 包含 DOCTYPE、html、head、body 等基本元素
* 如果这些元素缺失WebView 可能无法正常渲染
*/
it('should generate valid HTML with required elements', () => {
const html = WebViewContent.generate(mockPanel, mockExtensionUri);
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('<html lang="en">');
expect(html).toContain('<head>');
expect(html).toContain('<body');
expect(html).toContain('</html>');
});
/**
* 测试React 挂载点
*
* 验证 HTML 包含 id="root" 的 div这是 React 应用的挂载点
* 如果缺失React 应用将无法渲染
*/
it('should include React mount point (#root)', () => {
const html = WebViewContent.generate(mockPanel, mockExtensionUri);
expect(html).toContain('<div id="root"></div>');
});
/**
* 测试CSP (Content Security Policy) 配置
*
* 验证 HTML 包含正确的 CSP meta 标签
* CSP 用于防止 XSS 攻击,但配置不当会导致脚本无法加载
*/
it('should include Content-Security-Policy meta tag', () => {
const html = WebViewContent.generate(mockPanel, mockExtensionUri);
expect(html).toContain('Content-Security-Policy');
expect(html).toContain("default-src 'none'");
expect(html).toContain('script-src');
expect(html).toContain('style-src');
expect(html).toContain('img-src');
});
/**
* 测试:脚本引用
*
* 验证 HTML 包含 webview.js 的脚本引用
* 这是编译后的 React 应用入口,缺失会导致白屏
*/
it('should include webview.js script reference', () => {
const html = WebViewContent.generate(mockPanel, mockExtensionUri);
expect(html).toContain('<script src=');
expect(html).toContain('webview.js');
});
/**
* 测试Extension URI 属性
*
* 验证 body 元素包含 data-extension-uri 属性
* 前端代码使用此属性构建资源路径(如图标)
*/
it('should set data-extension-uri attribute on body', () => {
const html = WebViewContent.generate(mockPanel, mockExtensionUri);
expect(html).toContain('data-extension-uri=');
});
/**
* 测试XSS 防护
*
* 验证特殊字符被正确转义,防止 XSS 攻击
* 如果 URI 包含恶意脚本,应该被转义而不是执行
*/
it('should escape HTML in URIs to prevent XSS', () => {
// 模拟包含特殊字符的 URI
mockPanel.webview.asWebviewUri = vi.fn((_localResource: { fsPath: string }) => ({
toString: () => 'vscode-webview://resource&lt;script&gt;alert(1)&lt;/script&gt;',
} as any));
const html = WebViewContent.generate(mockPanel, mockExtensionUri);
// 确保 <script> 标签被转义
expect(html).not.toContain('<script>alert(1)</script>');
// 应该包含转义后的版本
expect(html).toMatch(/&lt;script&gt;|&#60;script&#62;/);
});
/**
* 测试Viewport meta 标签
*
* 验证 HTML 包含正确的 viewport 设置
* 这对于响应式布局很重要
*/
it('should include viewport meta tag', () => {
const html = WebViewContent.generate(mockPanel, mockExtensionUri);
expect(html).toContain('name="viewport"');
expect(html).toContain('width=device-width');
});
/**
* 测试:字符编码
*
* 验证 HTML 声明了 UTF-8 编码
* 缺失可能导致中文等字符显示乱码
*/
it('should declare UTF-8 charset', () => {
const html = WebViewContent.generate(mockPanel, mockExtensionUri);
expect(html).toContain('charset="UTF-8"');
});
/**
* 测试asWebviewUri 调用
*
* 验证正确调用了 asWebviewUri 来转换资源 URI
* 这是 VSCode WebView 安全机制的一部分
*/
it('should call asWebviewUri for resource paths', () => {
WebViewContent.generate(mockPanel, mockExtensionUri);
expect(mockPanel.webview.asWebviewUri).toHaveBeenCalled();
});
});

View File

@@ -1,116 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/** @vitest-environment jsdom */
/* eslint-disable import/no-internal-modules */
import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createRoot } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { fireEvent } from '@testing-library/dom';
import { PermissionDrawer } from './PermissionDrawer.js';
import type { PermissionOption, ToolCall } from './PermissionRequest.js';
const render = (ui: React.ReactElement) => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(ui);
});
return {
container,
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
};
const baseOptions: PermissionOption[] = [
{ name: 'Allow once', kind: 'allow_once', optionId: 'allow_once' },
{ name: 'Reject', kind: 'reject', optionId: 'reject' },
];
const baseToolCall: ToolCall = {
kind: 'edit',
title: 'Edit file',
locations: [{ path: '/repo/src/file.ts' }],
};
describe('PermissionDrawer', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('does not render when closed', () => {
const { container, unmount } = render(
<PermissionDrawer
isOpen={false}
options={baseOptions}
toolCall={baseToolCall}
onResponse={vi.fn()}
/>,
);
expect(container.textContent).toBe('');
unmount();
});
it('renders the affected file name for edits', () => {
const { container, unmount } = render(
<PermissionDrawer
isOpen
options={baseOptions}
toolCall={baseToolCall}
onResponse={vi.fn()}
/>,
);
expect(container.textContent).toContain('file.ts');
unmount();
});
it('selects the first option on number key press', () => {
const onResponse = vi.fn();
const { unmount } = render(
<PermissionDrawer
isOpen
options={baseOptions}
toolCall={baseToolCall}
onResponse={onResponse}
/>,
);
fireEvent.keyDown(window, { key: '1' });
expect(onResponse).toHaveBeenCalledWith('allow_once');
unmount();
});
it('rejects and closes on Escape', () => {
const onResponse = vi.fn();
const onClose = vi.fn();
const { unmount } = render(
<PermissionDrawer
isOpen
options={baseOptions}
toolCall={baseToolCall}
onResponse={onResponse}
onClose={onClose}
/>,
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onResponse).toHaveBeenCalledWith('reject');
expect(onClose).toHaveBeenCalled();
unmount();
});
});

View File

@@ -1,525 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* useMessageHandling Hook 测试
*
* 测试目标:确保消息处理逻辑正确,防止消息显示异常
*
* 关键测试场景:
* 1. 消息添加 - 确保消息能正确添加到列表
* 2. 流式响应 - 确保流式内容能逐块追加
* 3. 思考过程 - 确保 AI 思考过程正确处理
* 4. 状态管理 - 确保加载状态正确更新
* 5. 消息清除 - 确保能正确清空消息列表
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useMessageHandling, type TextMessage } from './useMessageHandling.js';
describe('useMessageHandling', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Initial State - 初始状态', () => {
/**
* 测试:初始状态
*
* 验证 hook 初始化时状态正确
* 确保不会有意外的初始消息或状态
*/
it('should have correct initial state', () => {
const { result } = renderHook(() => useMessageHandling());
expect(result.current.messages).toEqual([]);
expect(result.current.isStreaming).toBe(false);
expect(result.current.isWaitingForResponse).toBe(false);
expect(result.current.loadingMessage).toBe('');
});
});
describe('addMessage - 消息添加', () => {
/**
* 测试:添加用户消息
*
* 验证用户消息能正确添加到消息列表
*/
it('should add user message', () => {
const { result } = renderHook(() => useMessageHandling());
const message: TextMessage = {
role: 'user',
content: 'Hello, AI!',
timestamp: Date.now(),
};
act(() => {
result.current.addMessage(message);
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].role).toBe('user');
expect(result.current.messages[0].content).toBe('Hello, AI!');
});
/**
* 测试:添加 AI 回复
*
* 验证 AI 回复能正确添加到消息列表
*/
it('should add assistant message', () => {
const { result } = renderHook(() => useMessageHandling());
const message: TextMessage = {
role: 'assistant',
content: 'Hello! How can I help?',
timestamp: Date.now(),
};
act(() => {
result.current.addMessage(message);
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].role).toBe('assistant');
});
/**
* 测试:添加带文件上下文的消息
*
* 验证消息能包含文件上下文信息
*/
it('should add message with file context', () => {
const { result } = renderHook(() => useMessageHandling());
const message: TextMessage = {
role: 'user',
content: 'Fix this code',
timestamp: Date.now(),
fileContext: {
fileName: 'test.ts',
filePath: '/src/test.ts',
startLine: 1,
endLine: 10,
},
};
act(() => {
result.current.addMessage(message);
});
expect(result.current.messages[0].fileContext).toBeDefined();
expect(result.current.messages[0].fileContext?.fileName).toBe('test.ts');
});
/**
* 测试:消息顺序
*
* 验证多条消息按添加顺序排列
*/
it('should maintain message order', () => {
const { result } = renderHook(() => useMessageHandling());
act(() => {
result.current.addMessage({
role: 'user',
content: 'First',
timestamp: Date.now(),
});
result.current.addMessage({
role: 'assistant',
content: 'Second',
timestamp: Date.now(),
});
result.current.addMessage({
role: 'user',
content: 'Third',
timestamp: Date.now(),
});
});
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0].content).toBe('First');
expect(result.current.messages[1].content).toBe('Second');
expect(result.current.messages[2].content).toBe('Third');
});
});
describe('Streaming - 流式响应', () => {
/**
* 测试:开始流式响应
*
* 验证 startStreaming 正确设置状态并创建占位消息
*/
it('should start streaming and create placeholder', () => {
const { result } = renderHook(() => useMessageHandling());
act(() => {
result.current.startStreaming();
});
expect(result.current.isStreaming).toBe(true);
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].role).toBe('assistant');
expect(result.current.messages[0].content).toBe('');
});
/**
* 测试:追加流式内容
*
* 验证流式内容能逐块追加到占位消息
*/
it('should append stream chunks to placeholder', () => {
const { result } = renderHook(() => useMessageHandling());
act(() => {
result.current.startStreaming();
});
act(() => {
result.current.appendStreamChunk('Hello');
result.current.appendStreamChunk(' World');
result.current.appendStreamChunk('!');
});
expect(result.current.messages[0].content).toBe('Hello World!');
});
/**
* 测试:使用提供的时间戳
*
* 验证 startStreaming 能使用扩展提供的时间戳保持顺序
*/
it('should use provided timestamp for ordering', () => {
const { result } = renderHook(() => useMessageHandling());
const customTimestamp = 1000;
act(() => {
result.current.startStreaming(customTimestamp);
});
expect(result.current.messages[0].timestamp).toBe(customTimestamp);
});
/**
* 测试:结束流式响应
*
* 验证 endStreaming 正确重置状态
*/
it('should end streaming correctly', () => {
const { result } = renderHook(() => useMessageHandling());
act(() => {
result.current.startStreaming();
result.current.appendStreamChunk('Response content');
result.current.endStreaming();
});
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].content).toBe('Response content');
});
/**
* 测试:忽略流式结束后的晚到内容
*
* 验证用户取消后晚到的 chunk 被忽略
*/
it('should ignore chunks after streaming ends', () => {
const { result } = renderHook(() => useMessageHandling());
act(() => {
result.current.startStreaming();
result.current.appendStreamChunk('Hello');
result.current.endStreaming();
});
act(() => {
result.current.appendStreamChunk(' Late chunk');
});
expect(result.current.messages[0].content).toBe('Hello');
});
});
describe('breakAssistantSegment - 分段流式响应', () => {
/**
* 测试:打断当前流式段
*
* 验证工具调用插入时能打断当前流式段
*/
it('should break current segment and start new one on next chunk', () => {
const { result } = renderHook(() => useMessageHandling());
act(() => {
result.current.startStreaming();
result.current.appendStreamChunk('Part 1');
result.current.breakAssistantSegment();
});
act(() => {
result.current.appendStreamChunk('Part 2');
});
// 应该有两条 assistant 消息
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0].content).toBe('Part 1');
expect(result.current.messages[1].content).toBe('Part 2');
});
});
describe('Thinking - 思考过程', () => {
/**
* 测试:追加思考内容
*
* 验证 AI 思考过程能正确追加
*/
it('should append thinking chunks', () => {
const { result } = renderHook(() => useMessageHandling());
act(() => {
result.current.startStreaming();
result.current.appendThinkingChunk('Analyzing');
result.current.appendThinkingChunk(' the code');
});
const thinkingMsg = result.current.messages.find(
(m: TextMessage) => m.role === 'thinking',
);
expect(thinkingMsg).toBeDefined();
expect(thinkingMsg?.content).toBe('Analyzing the code');
});
/**
* 测试:流式结束时清除思考消息
*
* 验证流式结束后思考消息被移除
*/
it('should remove thinking message on end streaming', () => {
const { result } = renderHook(() => useMessageHandling());
act(() => {
result.current.startStreaming();
result.current.appendThinkingChunk('Thinking...');
result.current.appendStreamChunk('Response');
result.current.endStreaming();
});
const thinkingMsg = result.current.messages.find(
(m: TextMessage) => m.role === 'thinking',
);
expect(thinkingMsg).toBeUndefined();
});
/**
* 测试:手动清除思考消息
*
* 验证 clearThinking 正确移除思考消息
*/
it('should clear thinking message manually', () => {
const { result } = renderHook(() => useMessageHandling());
act(() => {
result.current.startStreaming();
result.current.appendThinkingChunk('Thinking...');
});
expect(
result.current.messages.find((m: TextMessage) => m.role === 'thinking'),
).toBeDefined();
act(() => {
result.current.clearThinking();
});
expect(
result.current.messages.find((m) => m.role === 'thinking'),
).toBeUndefined();
});
/**
* 测试:忽略流式结束后的思考内容
*
* 验证用户取消后晚到的思考内容被忽略
*/
it('should ignore thinking chunks after streaming ends', () => {
const { result } = renderHook(() => useMessageHandling());
act(() => {
result.current.startStreaming();
result.current.endStreaming();
});
act(() => {
result.current.appendThinkingChunk('Late thinking');
});
expect(
result.current.messages.find((m: TextMessage) => m.role === 'thinking'),
).toBeUndefined();
});
});
describe('Loading State - 加载状态', () => {
/**
* 测试:设置等待响应状态
*
* 验证 setWaitingForResponse 正确设置状态和消息
*/
it('should set waiting for response state', () => {
const { result } = renderHook(() => useMessageHandling());
act(() => {
result.current.setWaitingForResponse('AI is thinking...');
});
expect(result.current.isWaitingForResponse).toBe(true);
expect(result.current.loadingMessage).toBe('AI is thinking...');
});
/**
* 测试:清除等待响应状态
*
* 验证 clearWaitingForResponse 正确重置状态
*/
it('should clear waiting for response state', () => {
const { result } = renderHook(() => useMessageHandling());
act(() => {
result.current.setWaitingForResponse('Loading...');
result.current.clearWaitingForResponse();
});
expect(result.current.isWaitingForResponse).toBe(false);
expect(result.current.loadingMessage).toBe('');
});
});
describe('clearMessages - 消息清除', () => {
/**
* 测试:清除所有消息
*
* 验证 clearMessages 正确清空消息列表
*/
it('should clear all messages', () => {
const { result } = renderHook(() => useMessageHandling());
act(() => {
result.current.addMessage({
role: 'user',
content: 'Test 1',
timestamp: Date.now(),
});
result.current.addMessage({
role: 'assistant',
content: 'Test 2',
timestamp: Date.now(),
});
});
expect(result.current.messages).toHaveLength(2);
act(() => {
result.current.clearMessages();
});
expect(result.current.messages).toHaveLength(0);
});
});
describe('setMessages - 直接设置消息', () => {
/**
* 测试:直接设置消息列表
*
* 验证能直接替换整个消息列表(用于会话恢复)
*/
it('should set messages directly', () => {
const { result } = renderHook(() => useMessageHandling());
const messages: TextMessage[] = [
{ role: 'user', content: 'Hello', timestamp: 1000 },
{ role: 'assistant', content: 'Hi there!', timestamp: 1001 },
];
act(() => {
result.current.setMessages(messages);
});
expect(result.current.messages).toEqual(messages);
});
});
describe('Edge Cases - 边缘情况', () => {
/**
* 测试:空内容处理
*
* 验证空内容的处理不会导致问题
*/
it('should handle empty content', () => {
const { result } = renderHook(() => useMessageHandling());
act(() => {
result.current.addMessage({
role: 'user',
content: '',
timestamp: Date.now(),
});
});
expect(result.current.messages[0].content).toBe('');
});
/**
* 测试:大量消息
*
* 验证能处理大量消息而不崩溃
*/
it('should handle many messages', () => {
const { result } = renderHook(() => useMessageHandling());
act(() => {
for (let i = 0; i < 100; i++) {
result.current.addMessage({
role: i % 2 === 0 ? 'user' : 'assistant',
content: `Message ${i}`,
timestamp: Date.now() + i,
});
}
});
expect(result.current.messages).toHaveLength(100);
});
/**
* 测试:快速连续操作
*
* 验证快速连续的操作不会导致状态异常
*/
it('should handle rapid operations', () => {
const { result } = renderHook(() => useMessageHandling());
act(() => {
result.current.startStreaming();
result.current.appendStreamChunk('A');
result.current.appendStreamChunk('B');
result.current.appendStreamChunk('C');
result.current.endStreaming();
result.current.startStreaming();
result.current.appendStreamChunk('D');
result.current.endStreaming();
});
// 应该有两条 assistant 消息
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0].content).toBe('ABC');
expect(result.current.messages[1].content).toBe('D');
});
});
});

View File

@@ -1,289 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* useVSCode Hook 测试
*
* 测试目标:确保 VSCode API 通信正常,防止 WebView 与扩展通信失败
*
* 关键测试场景:
* 1. API 获取 - 确保能正确获取 VSCode API
* 2. postMessage - 确保消息能发送到扩展
* 3. getState/setState - 确保状态能正确持久化
* 4. 单例模式 - 确保 API 实例只创建一次
* 5. 降级处理 - 确保在非 VSCode 环境中有 fallback
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook } from '@testing-library/react';
// 声明全局类型
declare global {
var acquireVsCodeApi: (() => {
postMessage: (message: unknown) => void;
getState: () => unknown;
setState: (state: unknown) => void;
}) | undefined;
}
describe('useVSCode', () => {
let originalAcquireVsCodeApi: typeof globalThis.acquireVsCodeApi;
beforeEach(() => {
// 保存原始值
originalAcquireVsCodeApi = globalThis.acquireVsCodeApi;
// 重置模块以清除缓存的 API 实例
vi.resetModules();
});
afterEach(() => {
// 恢复原始值
globalThis.acquireVsCodeApi = originalAcquireVsCodeApi;
vi.restoreAllMocks();
});
describe('API Acquisition - VSCode API 获取', () => {
/**
* 测试:获取 VSCode API
*
* 验证在 VSCode 环境中能正确获取 API
* 这是 WebView 与扩展通信的基础
*/
it('should acquire VSCode API when available', async () => {
const mockApi = {
postMessage: vi.fn(),
getState: vi.fn(() => ({})),
setState: vi.fn(),
};
globalThis.acquireVsCodeApi = vi.fn(() => mockApi);
const { useVSCode } = await import('./useVSCode.js');
const { result } = renderHook(() => useVSCode());
expect(result.current).toBeDefined();
expect(result.current.postMessage).toBeDefined();
expect(result.current.getState).toBeDefined();
expect(result.current.setState).toBeDefined();
});
/**
* 测试:开发环境 fallback
*
* 验证在非 VSCode 环境中提供 mock 实现
* 允许在浏览器中开发和测试
*/
it('should provide fallback when acquireVsCodeApi is not available', async () => {
globalThis.acquireVsCodeApi = undefined;
const { useVSCode } = await import('./useVSCode.js');
const { result } = renderHook(() => useVSCode());
expect(result.current).toBeDefined();
expect(typeof result.current.postMessage).toBe('function');
expect(typeof result.current.getState).toBe('function');
expect(typeof result.current.setState).toBe('function');
});
});
describe('postMessage - 消息发送', () => {
/**
* 测试:发送消息到扩展
*
* 验证 postMessage 能正确调用 VSCode API
* 这是 WebView 向扩展发送命令的方式
*/
it('should call postMessage on VSCode API', async () => {
const mockPostMessage = vi.fn();
globalThis.acquireVsCodeApi = vi.fn(() => ({
postMessage: mockPostMessage,
getState: vi.fn(() => ({})),
setState: vi.fn(),
}));
const { useVSCode } = await import('./useVSCode.js');
const { result } = renderHook(() => useVSCode());
const testMessage = { type: 'test', data: { foo: 'bar' } };
result.current.postMessage(testMessage);
expect(mockPostMessage).toHaveBeenCalledWith(testMessage);
});
/**
* 测试:发送不同类型的消息
*
* 验证各种消息类型都能正确发送
*/
it('should handle different message types', async () => {
const mockPostMessage = vi.fn();
globalThis.acquireVsCodeApi = vi.fn(() => ({
postMessage: mockPostMessage,
getState: vi.fn(() => ({})),
setState: vi.fn(),
}));
const { useVSCode } = await import('./useVSCode.js');
const { result } = renderHook(() => useVSCode());
// 测试各种消息类型
const messages = [
{ type: 'sendMessage', data: { content: 'Hello' } },
{ type: 'cancelStreaming', data: {} },
{ type: 'newSession', data: {} },
{ type: 'permissionResponse', data: { optionId: 'allow' } },
{ type: 'login', data: {} },
];
messages.forEach((msg) => {
result.current.postMessage(msg);
});
expect(mockPostMessage).toHaveBeenCalledTimes(messages.length);
});
});
describe('getState/setState - 状态持久化', () => {
/**
* 测试:获取状态
*
* 验证能正确获取 WebView 持久化的状态
*/
it('should get state from VSCode API', async () => {
const mockState = { messages: [], sessionId: 'test-123' };
globalThis.acquireVsCodeApi = vi.fn(() => ({
postMessage: vi.fn(),
getState: vi.fn(() => mockState),
setState: vi.fn(),
}));
const { useVSCode } = await import('./useVSCode.js');
const { result } = renderHook(() => useVSCode());
const state = result.current.getState();
expect(state).toEqual(mockState);
});
/**
* 测试:设置状态
*
* 验证能正确设置 WebView 持久化状态
* 状态在 WebView 隐藏后仍能保留
*/
it('should set state on VSCode API', async () => {
const mockSetState = vi.fn();
globalThis.acquireVsCodeApi = vi.fn(() => ({
postMessage: vi.fn(),
getState: vi.fn(() => ({})),
setState: mockSetState,
}));
const { useVSCode } = await import('./useVSCode.js');
const { result } = renderHook(() => useVSCode());
const newState = { messages: [{ content: 'test' }] };
result.current.setState(newState);
expect(mockSetState).toHaveBeenCalledWith(newState);
});
});
describe('Singleton Pattern - 单例模式', () => {
/**
* 测试API 实例只创建一次
*
* 验证 acquireVsCodeApi 只被调用一次
* VSCode 要求此函数只能调用一次
*/
it('should only call acquireVsCodeApi once', async () => {
const mockAcquire = vi.fn(() => ({
postMessage: vi.fn(),
getState: vi.fn(() => ({})),
setState: vi.fn(),
}));
globalThis.acquireVsCodeApi = mockAcquire;
const { useVSCode } = await import('./useVSCode.js');
// 多次调用 hook
renderHook(() => useVSCode());
renderHook(() => useVSCode());
renderHook(() => useVSCode());
// acquireVsCodeApi 应该只被调用一次
expect(mockAcquire).toHaveBeenCalledTimes(1);
});
/**
* 测试:多个组件共享同一实例
*
* 验证不同组件获取的是同一个 API 实例
*/
it('should return same instance across multiple hooks', async () => {
const mockApi = {
postMessage: vi.fn(),
getState: vi.fn(() => ({})),
setState: vi.fn(),
};
globalThis.acquireVsCodeApi = vi.fn(() => mockApi);
const { useVSCode } = await import('./useVSCode.js');
const { result: result1 } = renderHook(() => useVSCode());
const { result: result2 } = renderHook(() => useVSCode());
// 应该是同一个实例
expect(result1.current).toBe(result2.current);
});
});
describe('Fallback Behavior - 降级行为', () => {
/**
* 测试Fallback postMessage 不会报错
*
* 验证在开发环境中 mock 的 postMessage 能正常工作
*/
it('should not throw on fallback postMessage', async () => {
globalThis.acquireVsCodeApi = undefined;
const { useVSCode } = await import('./useVSCode.js');
const { result } = renderHook(() => useVSCode());
expect(() => {
result.current.postMessage({ type: 'test', data: {} });
}).not.toThrow();
});
/**
* 测试Fallback getState 返回空对象
*
* 验证在开发环境中 getState 返回空对象
*/
it('should return empty object on fallback getState', async () => {
globalThis.acquireVsCodeApi = undefined;
const { useVSCode } = await import('./useVSCode.js');
const { result } = renderHook(() => useVSCode());
const state = result.current.getState();
expect(state).toEqual({});
});
/**
* 测试Fallback setState 不会报错
*
* 验证在开发环境中 mock 的 setState 能正常工作
*/
it('should not throw on fallback setState', async () => {
globalThis.acquireVsCodeApi = undefined;
const { useVSCode } = await import('./useVSCode.js');
const { result } = renderHook(() => useVSCode());
expect(() => {
result.current.setState({ test: 'value' });
}).not.toThrow();
});
});
});

View File

@@ -1,337 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/** @vitest-environment jsdom */
/* eslint-disable import/no-internal-modules */
import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createRoot } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import type {
PermissionOption,
ToolCall as PermissionToolCall,
} from '../components/PermissionDrawer/PermissionRequest.js';
import type { ToolCallUpdate } from '../../types/chatTypes.js';
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
import type { PlanEntry, UsageStatsPayload } from '../../types/chatTypes.js';
import type { ModelInfo } from '../../types/acpTypes.js';
declare global {
var acquireVsCodeApi:
| undefined
| (() => {
postMessage: (message: unknown) => void;
getState: () => unknown;
setState: (state: unknown) => void;
});
}
interface WebViewMessageProps {
sessionManagement: {
currentSessionId: string | null;
setQwenSessions: (
sessions:
| Array<Record<string, unknown>>
| ((
prev: Array<Record<string, unknown>>,
) => Array<Record<string, unknown>>),
) => void;
setCurrentSessionId: (id: string | null) => void;
setCurrentSessionTitle: (title: string) => void;
setShowSessionSelector: (show: boolean) => void;
setNextCursor: (cursor: number | undefined) => void;
setHasMore: (hasMore: boolean) => void;
setIsLoading: (loading: boolean) => void;
handleSaveSessionResponse: (response: {
success: boolean;
message?: string;
}) => void;
};
fileContext: {
setActiveFileName: (name: string | null) => void;
setActiveFilePath: (path: string | null) => void;
setActiveSelection: (
selection: { startLine: number; endLine: number } | null,
) => void;
setWorkspaceFiles: (
files: Array<{
id: string;
label: string;
description: string;
path: string;
}>,
) => void;
addFileReference: (name: string, path: string) => void;
};
messageHandling: {
setMessages: (
messages: Array<{
role: 'user' | 'assistant' | 'thinking';
content: string;
timestamp: number;
fileContext?: {
fileName: string;
filePath: string;
startLine?: number;
endLine?: number;
};
}>,
) => void;
addMessage: (message: {
role: 'user' | 'assistant' | 'thinking';
content: string;
timestamp: number;
}) => void;
clearMessages: () => void;
startStreaming: (timestamp?: number) => void;
appendStreamChunk: (chunk: string) => void;
endStreaming: () => void;
breakAssistantSegment: () => void;
appendThinkingChunk: (chunk: string) => void;
clearThinking: () => void;
setWaitingForResponse: (message: string) => void;
clearWaitingForResponse: () => void;
};
handleToolCallUpdate: (update: ToolCallUpdate) => void;
clearToolCalls: () => void;
setPlanEntries: (entries: PlanEntry[]) => void;
handlePermissionRequest: (
request: {
options: PermissionOption[];
toolCall: PermissionToolCall;
} | null,
) => void;
inputFieldRef: React.RefObject<HTMLDivElement>;
setInputText: (text: string) => void;
setEditMode?: (mode: ApprovalModeValue) => void;
setIsAuthenticated?: (authenticated: boolean | null) => void;
setUsageStats?: (stats: UsageStatsPayload | undefined) => void;
setModelInfo?: (info: ModelInfo | null) => void;
}
const createProps = (overrides: Partial<WebViewMessageProps> = {}) => {
const props: WebViewMessageProps = {
sessionManagement: {
currentSessionId: null,
setQwenSessions: vi.fn(),
setCurrentSessionId: vi.fn(),
setCurrentSessionTitle: vi.fn(),
setShowSessionSelector: vi.fn(),
setNextCursor: vi.fn(),
setHasMore: vi.fn(),
setIsLoading: vi.fn(),
handleSaveSessionResponse: vi.fn(),
},
fileContext: {
setActiveFileName: vi.fn(),
setActiveFilePath: vi.fn(),
setActiveSelection: vi.fn(),
setWorkspaceFiles: vi.fn(),
addFileReference: vi.fn(),
},
messageHandling: {
setMessages: vi.fn(),
addMessage: vi.fn(),
clearMessages: vi.fn(),
startStreaming: vi.fn(),
appendStreamChunk: vi.fn(),
endStreaming: vi.fn(),
breakAssistantSegment: vi.fn(),
appendThinkingChunk: vi.fn(),
clearThinking: vi.fn(),
setWaitingForResponse: vi.fn(),
clearWaitingForResponse: vi.fn(),
},
handleToolCallUpdate: vi.fn(),
clearToolCalls: vi.fn(),
setPlanEntries: vi.fn(),
handlePermissionRequest: vi.fn(),
inputFieldRef: React.createRef<HTMLDivElement>(),
setInputText: vi.fn(),
setEditMode: vi.fn(),
setIsAuthenticated: vi.fn(),
setUsageStats: vi.fn(),
setModelInfo: vi.fn(),
};
return {
...props,
...overrides,
sessionManagement: {
...props.sessionManagement,
...overrides.sessionManagement,
},
fileContext: {
...props.fileContext,
...overrides.fileContext,
},
messageHandling: {
...props.messageHandling,
...overrides.messageHandling,
},
};
};
const renderHook = async (props: WebViewMessageProps) => {
const { useWebViewMessages } = await import('./useWebViewMessages.js');
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const Harness = () => {
useWebViewMessages(props);
return <div ref={props.inputFieldRef} />;
};
await act(async () => {
root.render(<Harness />);
});
await act(async () => {});
return {
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
};
const setup = async (overrides: Partial<WebViewMessageProps> = {}) => {
vi.resetModules();
const postMessage = vi.fn();
globalThis.acquireVsCodeApi = () => ({
postMessage,
getState: vi.fn(),
setState: vi.fn(),
});
const props = createProps(overrides);
const { unmount } = await renderHook(props);
return { postMessage, props, unmount };
};
describe('useWebViewMessages', () => {
afterEach(() => {
document.body.innerHTML = '';
globalThis.acquireVsCodeApi = undefined;
});
it('opens a diff when permission request includes diff content', async () => {
const { postMessage, props, unmount } = await setup();
const diffContent = {
type: 'diff',
path: 'src/example.ts',
oldText: 'old',
newText: 'new',
};
act(() => {
window.dispatchEvent(
new MessageEvent('message', {
data: {
type: 'permissionRequest',
data: {
options: [
{ name: 'Allow once', kind: 'allow_once', optionId: 'allow' },
],
toolCall: {
toolCallId: 'tc-1',
title: 'Edit file',
kind: 'execute',
status: 'pending',
content: [diffContent],
},
},
},
}),
);
});
expect(postMessage).toHaveBeenCalledWith({
type: 'openDiff',
data: diffContent,
});
expect(props.handleToolCallUpdate).toHaveBeenCalled();
const update = vi.mocked(props.handleToolCallUpdate).mock.calls[0][0];
expect(update.type).toBe('tool_call');
expect(update.toolCallId).toBe('tc-1');
expect(update.kind).toBe('edit');
unmount();
});
it('closes permission drawer when extension resolves permission', async () => {
const { props, unmount } = await setup();
act(() => {
window.dispatchEvent(
new MessageEvent('message', {
data: {
type: 'permissionResolved',
data: { optionId: 'allow' },
},
}),
);
});
expect(props.handlePermissionRequest).toHaveBeenCalledWith(null);
unmount();
});
it('merges plan updates into a single tool call', async () => {
const { props, unmount } = await setup();
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
const initialPlan: PlanEntry[] = [
{ content: 'Step 1', status: 'completed' },
];
act(() => {
window.dispatchEvent(
new MessageEvent('message', {
data: { type: 'plan', data: { entries: initialPlan } },
}),
);
});
const firstCall = vi.mocked(props.handleToolCallUpdate).mock.calls[0][0];
vi.setSystemTime(new Date('2024-01-01T00:00:01Z'));
const updatedPlan: PlanEntry[] = [
{ content: 'Step 1', status: 'completed' },
{ content: 'Step 2', status: 'in_progress' },
];
act(() => {
window.dispatchEvent(
new MessageEvent('message', {
data: { type: 'plan', data: { entries: updatedPlan } },
}),
);
});
const secondCall = vi.mocked(props.handleToolCallUpdate).mock.calls[1][0];
expect(firstCall.type).toBe('tool_call');
expect(secondCall.type).toBe('tool_call_update');
expect(secondCall.toolCallId).toBe(firstCall.toolCallId);
vi.useRealTimers();
unmount();
});
});

View File

@@ -1,158 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* 测试用 Mock 数据工厂
*
* 提供创建测试数据的工厂函数,确保测试数据的一致性和可维护性
*/
import { vi } from 'vitest';
/**
* 创建 Mock Tool Call 数据
*
* Tool Call 是 AI 执行工具操作时的数据结构,
* 包含工具类型、状态、输入输出等信息
*
* @param overrides 覆盖默认值的属性
*/
export const createMockToolCall = (overrides: Record<string, unknown> = {}) => ({
toolCallId: 'test-tool-call-id',
kind: 'execute' as const,
title: 'Test Tool Call',
status: 'pending' as const,
timestamp: Date.now(),
rawInput: {},
...overrides,
});
/**
* 创建 Mock 消息数据
*
* 消息是聊天界面中的基本单元,
* 包含用户消息、AI 回复、思考过程等
*
* @param overrides 覆盖默认值的属性
*/
export const createMockMessage = (overrides: Record<string, unknown> = {}) => ({
role: 'user' as const,
content: 'Test message',
timestamp: Date.now(),
...overrides,
});
/**
* 创建 Mock 会话数据
*
* 会话包含一组相关的消息,支持历史记录和会话切换
*
* @param overrides 覆盖默认值的属性
*/
export const createMockSession = (overrides: Record<string, unknown> = {}) => ({
id: 'test-session-id',
title: 'Test Session',
createdAt: Date.now(),
updatedAt: Date.now(),
messageCount: 0,
...overrides,
});
/**
* 创建 Mock 权限请求数据
*
* 权限请求在 AI 需要执行敏感操作时触发,
* 用户需要选择允许或拒绝
*
* @param overrides 覆盖默认值的属性
*/
export const createMockPermissionRequest = (overrides: Record<string, unknown> = {}) => ({
toolCall: {
toolCallId: 'test-tool-call-id',
title: 'Read file',
kind: 'read',
},
options: [
{ optionId: 'allow_once', label: 'Allow once', kind: 'allow' },
{ optionId: 'allow_always', label: 'Allow always', kind: 'allow' },
{ optionId: 'cancel', label: 'Cancel', kind: 'reject' },
],
...overrides,
});
/**
* 创建 Mock WebView Panel
*
* WebView Panel 是 VSCode 中显示自定义 UI 的容器
*
* @param overrides 覆盖默认值的属性
*/
export const createMockWebviewPanel = (overrides: Record<string, unknown> = {}) => ({
webview: {
html: '',
options: {},
asWebviewUri: vi.fn((uri) => ({
toString: () => `vscode-webview://resource${uri.fsPath}`,
})),
cspSource: 'vscode-webview:',
onDidReceiveMessage: vi.fn(() => ({ dispose: vi.fn() })),
postMessage: vi.fn(),
},
viewType: 'qwenCode.chat',
title: 'Qwen Code',
iconPath: null,
visible: true,
active: true,
viewColumn: 1,
onDidDispose: vi.fn(() => ({ dispose: vi.fn() })),
onDidChangeViewState: vi.fn(() => ({ dispose: vi.fn() })),
reveal: vi.fn(),
dispose: vi.fn(),
...overrides,
});
/**
* 创建 Mock Extension Context
*
* Extension Context 提供扩展运行时的上下文信息
*
* @param overrides 覆盖默认值的属性
*/
export const createMockExtensionContext = (overrides: Record<string, unknown> = {}) => ({
subscriptions: [],
extensionUri: { fsPath: '/path/to/extension' },
extensionPath: '/path/to/extension',
globalState: {
get: vi.fn(),
update: vi.fn(),
keys: vi.fn(() => []),
},
workspaceState: {
get: vi.fn(),
update: vi.fn(),
keys: vi.fn(() => []),
},
environmentVariableCollection: {
replace: vi.fn(),
clear: vi.fn(),
},
extension: {
packageJSON: { version: '1.0.0' },
},
...overrides,
});
/**
* 创建 Mock Diff Info
*
* Diff Info 包含代码对比的信息
*
* @param overrides 覆盖默认值的属性
*/
export const createMockDiffInfo = (overrides: Record<string, unknown> = {}) => ({
filePath: '/test/file.ts',
oldContent: 'const x = 1;',
newContent: 'const x = 2;',
...overrides,
});

View File

@@ -1,89 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* WebView 组件测试渲染工具
*
* 提供带有必要 Provider 和 mock 的渲染函数,
* 简化 WebView React 组件的测试
*/
import React from 'react';
import { render, type RenderOptions } from '@testing-library/react';
import { vi } from 'vitest';
/**
* Mock VSCode WebView API
*
* WebView 中的组件通过 acquireVsCodeApi() 获取此 API
* 用于与 VSCode 扩展进行双向通信
*/
export const mockVSCodeAPI = {
/** 向扩展发送消息 */
postMessage: vi.fn(),
/** 获取 WebView 持久化状态 */
getState: vi.fn(() => ({})),
/** 设置 WebView 持久化状态 */
setState: vi.fn(),
};
/**
* 测试用 Provider 包装器
*
* 如果组件需要特定的 Context Provider可以在这里添加
*/
const AllTheProviders: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return <>{children}</>;
};
/**
* 带 Provider 的渲染函数
*
* 使用方式:
* ```tsx
* import { renderWithProviders, screen } from './test-utils/render';
*
* it('should render component', () => {
* renderWithProviders(<MyComponent />);
* expect(screen.getByText('Hello')).toBeInTheDocument();
* });
* ```
*/
export const renderWithProviders = (
ui: React.ReactElement,
options?: Omit<RenderOptions, 'wrapper'>,
) => render(ui, { wrapper: AllTheProviders, ...options });
/**
* 模拟从扩展接收消息
*
* WebView 通过 window.addEventListener('message', ...) 接收消息
* 使用此函数模拟扩展发送的消息
*
* @param type 消息类型
* @param data 消息数据
*
* 使用示例:
* ```ts
* simulateExtensionMessage('authState', { authenticated: true });
* ```
*/
export const simulateExtensionMessage = (type: string, data: unknown) => {
window.dispatchEvent(
new MessageEvent('message', {
data: { type, data },
}),
);
};
/**
* 等待异步状态更新
*
* 用于等待 React 状态更新完成后再进行断言
*/
export const waitForStateUpdate = () =>
new Promise((resolve) => setTimeout(resolve, 0));
// 导出 @testing-library/react 的所有工具以及其他辅助函数
export * from '@testing-library/react';

View File

@@ -1 +0,0 @@
Sample file for VS Code integration tests.

View File

@@ -1,25 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
const path = require('node:path');
const { runTests } = require('@vscode/test-electron');
async function main() {
const extensionDevelopmentPath = path.resolve(__dirname, '..');
const extensionTestsPath = path.resolve(__dirname, 'suite/index.cjs');
const workspacePath = path.resolve(__dirname, 'fixtures/workspace');
await runTests({
extensionDevelopmentPath,
extensionTestsPath,
launchArgs: [workspacePath, '--disable-workspace-trust'],
});
}
main().catch((error) => {
console.error('Failed to run VS Code integration tests:', error);
process.exit(1);
});

View File

@@ -1,141 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
const assert = require('node:assert');
const path = require('node:path');
const vscode = require('vscode');
const CHAT_VIEW_TYPES = new Set([
'mainThreadWebview-qwenCode.chat',
'qwenCode.chat',
]);
function isWebviewInput(input) {
return !!input && typeof input === 'object' && 'viewType' in input;
}
function isDiffInput(input) {
return !!input && typeof input === 'object' && 'modified' in input;
}
function getAllTabs() {
return vscode.window.tabGroups.all.flatMap((group) => group.tabs);
}
function getChatTabs() {
return getAllTabs().filter((tab) => {
const input = tab.input;
return isWebviewInput(input) && CHAT_VIEW_TYPES.has(input.viewType);
});
}
function getQwenDiffTabs() {
return getAllTabs().filter((tab) => {
const input = tab.input;
if (!isDiffInput(input)) {
return false;
}
const original = input.original;
const modified = input.modified;
return (
(original && original.scheme === 'qwen-diff') ||
(modified && modified.scheme === 'qwen-diff')
);
});
}
async function waitFor(condition, timeoutMs = 5000, intervalMs = 100) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (condition()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error('Timed out waiting for condition.');
}
async function runTest(name, fn) {
try {
await fn();
console.log(`[integration] ${name}: OK`);
} catch (error) {
console.error(`[integration] ${name}: FAILED`);
throw error;
}
}
async function activateExtension() {
const extension = vscode.extensions.getExtension(
'qwenlm.qwen-code-vscode-ide-companion',
);
assert.ok(extension, 'Extension not found.');
await extension.activate();
}
async function ensureChatOpen() {
await vscode.commands.executeCommand('qwen-code.openChat');
await waitFor(() => getChatTabs().length > 0);
}
async function testOpenChatReusesPanel() {
await ensureChatOpen();
const before = getChatTabs().length;
await vscode.commands.executeCommand('qwen-code.openChat');
await new Promise((resolve) => setTimeout(resolve, 300));
const after = getChatTabs().length;
assert.strictEqual(
after,
before,
'openChat should reuse the existing webview panel.',
);
}
async function testOpenNewChatTabCreatesPanel() {
await ensureChatOpen();
const before = getChatTabs().length;
await vscode.commands.executeCommand('qwenCode.openNewChatTab');
await waitFor(() => getChatTabs().length === before + 1);
}
async function testShowDiffOpensDiffTab() {
await ensureChatOpen();
const workspace = vscode.workspace.workspaceFolders?.[0];
assert.ok(workspace, 'Workspace folder not found.');
const samplePath = path.join(workspace.uri.fsPath, 'sample.txt');
await vscode.commands.executeCommand('qwenCode.showDiff', {
path: samplePath,
oldText: 'before',
newText: 'after',
});
await waitFor(() => getQwenDiffTabs().length > 0);
}
async function cleanupEditors() {
try {
await vscode.commands.executeCommand('workbench.action.closeAllEditors');
} catch {
// Best effort cleanup; ignore failures.
}
}
async function runExtensionTests() {
await activateExtension();
await runTest('openChat reuses an existing webview', testOpenChatReusesPanel);
await runTest('openNewChatTab opens a new webview', testOpenNewChatTabCreatesPanel);
await runTest('showDiff opens a qwen-diff editor', testShowDiffOpensDiffTab);
await cleanupEditors();
}
module.exports = { runExtensionTests };

View File

@@ -1,13 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
const { runExtensionTests } = require('./extension.test.cjs');
async function run() {
await runExtensionTests();
}
module.exports = { run };

View File

@@ -7,12 +7,10 @@
"jsx": "react-jsx",
"jsxImportSource": "react",
"sourceMap": true,
"strict": true /* enable all strict type-checking options */,
"skipLibCheck": true
"strict": true /* enable all strict type-checking options */
/* Additional Checks */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
},
"include": ["src/**/*", "src/types/**/*"]
}
}

View File

@@ -1,41 +1,15 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
test: {
globals: true,
// 使用 jsdom 环境以支持 DOM 测试WebView 组件测试需要)
environment: 'jsdom',
environment: 'node',
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
// 全局测试 setup 文件
setupFiles: ['./src/test-setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'clover'],
include: ['src/**/*.ts', 'src/**/*.tsx'],
exclude: [
'src/**/*.test.ts',
'src/**/*.test.tsx',
'src/**/*.d.ts',
'src/test-setup.ts',
'src/**/test-utils/**',
],
},
// 测试超时时间(集成测试可能需要更长时间)
testTimeout: 10000,
// 依赖处理配置
deps: {
// 确保 vscode 模块可以被正确 mock
interopDefault: true,
},
},
// resolve 配置,使 vscode 模块能被正确识别为虚拟模块并被 mock
resolve: {
alias: {
vscode: path.resolve(__dirname, 'src/__mocks__/vscode.ts'),
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/**/*.d.ts'],
},
},
});