已开启
[feature][ide-plugins] plugins test added #69
Holmes_KHX创建于 12 天前
[feature][ide-plugins] plugins test added #69
已开启
共 9 个文件变更+1308-3
| @@ -35,6 +35,29 @@ | |||
| 35 | 35 | ||
| 36 | 如果仅需使用 CJMP 插件,也可以直接从 CJMP SDK 的 `cjmp-tools/plugins/` 目录获取现成的 `.vsix` 文件。 | 36 | 如果仅需使用 CJMP 插件,也可以直接从 CJMP SDK 的 `cjmp-tools/plugins/` 目录获取现成的 `.vsix` 文件。 |
| 37 | 37 | ||
| 38 | +## 测试与验证 | ||
| 39 | + | ||
| 40 | +本仓库包含两类自动化验证: | ||
| 41 | + | ||
| 42 | +1. **单元/集成测试与覆盖率**(Mocha + c8): | ||
| 43 | + | ||
| 44 | + ```bash | ||
| 45 | + npm run test:coverage | ||
| 46 | + ``` | ||
| 47 | + | ||
| 48 | +2. **Cangjie 语言服务器(LSP)验证**,用于确认语法高亮、跳转、悬停、补全等功能正常,需要本机已安装 Cangjie 插件并配置 `CJMP_SDK_HOME`: | ||
| 49 | + | ||
| 50 | + ```bash | ||
| 51 | + export CJMP_SDK_HOME=/path/to/cjmp-sdk | ||
| 52 | + npm run test:lsp | ||
| 53 | + ``` | ||
| 54 | + | ||
| 55 | + 其中 `npm run test:lsp:protocol` 运行 `test/lsp-protocol/` 下的 mocha 套件,直接以 LSP 协议握手 LSPServer 并逐项断言响应;`npm run test:lsp:e2e` 由 `test/lsp-e2e/run-lsp-e2e.mjs` 在真实 VS Code 扩展宿主中加载 Cangjie 插件,运行 `test/lsp-e2e/` 下的端到端用例,通过 `vscode.executeDefinitionProvider` 等内置命令验证。 | ||
| 56 | + | ||
| 57 | + e2e 忠实复现真实用户环境:测试时把已安装的 Cangjie 插件原样复制进测试宿主,**不做任何修补**。测试的意义在于暴露真实问题,而非追求绿灯——在 Cangjie 插件 1.1.0 + CJMP SDK 0.6.2 下,server 支撑的跳转/悬停/语义高亮/补全会失败(已知缺陷,正是测试要暴露的):插件 ① macOS/Linux 下只传 `CANGJIE_PATH` 不传 `CANGJIE_HOME`,server 无法定位标准库;② 总发无效 `stdLibPathOption`(SDK 不含 `lib/src`),致 server 语义结果全为 null。仅 TextMate 文法高亮可用。protocol 套件(直接握手原始 server 并正确传参)全绿,可证问题出在插件层而非 server 本身。 | ||
| 58 | + | ||
| 59 | + 其他环境变量:`CANGJIE_LSP_HOME`(直接指定 cangjie 工具链根目录)、`CANGJIE_VSCODE_EXTENSION_DIR`(指定 Cangjie 插件目录)、`CANGJIE_E2E_VSCODE_VERSION`(指定测试宿主 VS Code 版本,默认 1.130.0)。 | ||
| 60 | + | ||
| 38 | ## 相关文档 | 61 | ## 相关文档 |
| 39 | 62 | ||
| 40 | CJMP 插件的获取方式和具体使用说明请参考: | 63 | CJMP 插件的获取方式和具体使用说明请参考: |
| @@ -154,7 +154,10 @@ | |||
| 154 | "lint": "eslint src test", | 154 | "lint": "eslint src test", |
| 155 | "test": "npm run test:coverage", | 155 | "test": "npm run test:coverage", |
| 156 | "test:integration": "vscode-test", | 156 | "test:integration": "vscode-test", |
| 157 | - "test:coverage": "npm run compile-tests && npm run compile:coverage && npm run lint && node scripts/run-vscode-coverage.mjs" | 157 | + "test:coverage": "npm run compile-tests && npm run compile:coverage && npm run lint && node scripts/run-vscode-coverage.mjs", |
| 158 | + "test:lsp": "npm run test:lsp:protocol && npm run test:lsp:e2e", | ||
| 159 | + "test:lsp:protocol": "npm run compile-tests && mocha --ui tdd --reporter spec --timeout 240000 \"out/test/lsp-protocol/**/*.test.js\"", | ||
| 160 | + "test:lsp:e2e": "npm run compile-tests && node test/lsp-e2e/run-lsp-e2e.mjs" | ||
| 158 | }, | 161 | }, |
| 159 | "devDependencies": { | 162 | "devDependencies": { |
| 160 | "@types/ini": "^4.1.1", | 163 | "@types/ini": "^4.1.1", |
| @@ -165,7 +168,7 @@ | |||
| 165 | "@typescript-eslint/eslint-plugin": "^8.28.0", | 168 | "@typescript-eslint/eslint-plugin": "^8.28.0", |
| 166 | "@typescript-eslint/parser": "^8.28.0", | 169 | "@typescript-eslint/parser": "^8.28.0", |
| 167 | "@vscode/test-cli": "^0.0.10", | 170 | "@vscode/test-cli": "^0.0.10", |
| 168 | - "@vscode/test-electron": "^2.4.1", | 171 | + "@vscode/test-electron": "^3.1.0", |
| 169 | "c8": "^10.1.3", | 172 | "c8": "^10.1.3", |
| 170 | "eslint": "^9.23.0", | 173 | "eslint": "^9.23.0", |
| 171 | "mocha": "^11.7.5", | 174 | "mocha": "^11.7.5", |
| @@ -17,7 +17,7 @@ import { runTests } from '@vscode/test-electron'; | |||
| 17 | import { glob } from 'glob'; | 17 | import { glob } from 'glob'; |
| 18 | import { mkdir, rm, stat, readFile } from 'node:fs/promises'; | 18 | import { mkdir, rm, stat, readFile } from 'node:fs/promises'; |
| 19 | import path from 'node:path'; | 19 | import path from 'node:path'; |
| 20 | -import { fileURLToPath } from 'node:url'; | 20 | +import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 21 | import { spawn } from 'node:child_process'; | 21 | import { spawn } from 'node:child_process'; |
| 22 | import process from 'node:process'; | 22 | import process from 'node:process'; |
| 23 | 23 | ||
| @@ -112,6 +112,7 @@ async function main() { | |||
| 112 | await assertFile(path.join(coverageReportDir, 'index.html')); | 112 | await assertFile(path.join(coverageReportDir, 'index.html')); |
| 113 | await assertFile(path.join(coverageReportDir, 'lcov.info')); | 113 | await assertFile(path.join(coverageReportDir, 'lcov.info')); |
| 114 | await assertFile(path.join(coverageReportDir, 'coverage-summary.json')); | 114 | await assertFile(path.join(coverageReportDir, 'coverage-summary.json')); |
| 115 | + console.log(`Coverage HTML: ${pathToFileURL(path.join(coverageReportDir, 'index.html')).href}`); | ||
| 115 | await assertCoveredSourceEntry('src/keelsBuild.ts'); | 116 | await assertCoveredSourceEntry('src/keelsBuild.ts'); |
| 116 | await assertCoveredSourceEntry('src/keelsRun.ts'); | 117 | await assertCoveredSourceEntry('src/keelsRun.ts'); |
| 117 | await assertNoCoverageEntry('/node_modules/'); | 118 | await assertNoCoverageEntry('/node_modules/'); |
| @@ -0,0 +1,11 @@ | |||
| 1 | +{ | ||
| 2 | + "terminal.integrated.env.osx": { | ||
| 3 | + "ANDROID_SDK_ROOT": "${ANDROID_SDK_ROOT:-}", | ||
| 4 | + "CJMP_SDK_HOME": "${CJMP_SDK_HOME:-}", | ||
| 5 | + "DEVECO_CANGJIE_HOME": "/Users/user/Desktop/CJMP_SDK/compatibility", | ||
| 6 | + "ABI": "arm64-v8a", | ||
| 7 | + "AARCH64_LIBS": "/Users/user/Desktop/CJMP_SDK/compatibility/api/lib/linux_ohos_aarch64_cjnative/ohos", | ||
| 8 | + "AARCH64_KIT_LIBS": "/Users/user/Desktop/CJMP_SDK/compatibility/api/lib/linux_ohos_aarch64_cjnative/kit", | ||
| 9 | + "AARCH64_MACRO_LIBS": "/Users/user/Desktop/CJMP_SDK/compatibility/api/macro/ohos" | ||
| 10 | + } | ||
| 11 | +} | ||
| @@ -0,0 +1,253 @@ | |||
| 1 | +/* | ||
| 2 | + * Copyright (c) 2026 Huawei Device Co., Ltd. | ||
| 3 | + * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 4 | + * you may not use this file except in compliance with the License. | ||
| 5 | + * You may obtain a copy of the License at | ||
| 6 | + * | ||
| 7 | + * http://www.apache.org/licenses/LICENSE-2.0 | ||
| 8 | + * | ||
| 9 | + * Unless required by applicable law or agreed to in writing, software | ||
| 10 | + * distributed under the License is distributed on an "AS IS" BASIS, | ||
| 11 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 12 | + * See the License for the specific language governing permissions and | ||
| 13 | + * limitations under the License. | ||
| 14 | + */ | ||
| 15 | + | ||
| 16 | +import { spawn, ChildProcess } from 'child_process'; | ||
| 17 | +import * as fs from 'fs'; | ||
| 18 | +import * as os from 'os'; | ||
| 19 | +import * as path from 'path'; | ||
| 20 | + | ||
| 21 | +/** Launch arguments matching what the Cangjie VS Code extension passes to LSPServer. */ | ||
| 22 | +export const LSP_SERVER_ARGS = ['src', '--disableAutoImport', '--enable-log=false']; | ||
| 23 | + | ||
| 24 | +export const LSP_REQUEST_TIMEOUT_MS = 30_000; | ||
| 25 | +export const LSP_NOTIFICATION_TIMEOUT_MS = 60_000; | ||
| 26 | + | ||
| 27 | +export interface LspJsonMessage { | ||
| 28 | + jsonrpc: string; | ||
| 29 | + id?: number | string; | ||
| 30 | + method?: string; | ||
| 31 | + params?: unknown; | ||
| 32 | + result?: unknown; | ||
| 33 | + error?: unknown; | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +interface PendingRequest { | ||
| 37 | + resolve: (message: LspJsonMessage) => void; | ||
| 38 | + reject: (error: Error) => void; | ||
| 39 | + timer: NodeJS.Timeout; | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +/** | ||
| 43 | + * Resolves the cangjie toolchain root that contains tools/bin/LSPServer. | ||
| 44 | + * CANGJIE_LSP_HOME wins; otherwise derived from CJMP_SDK_HOME | ||
| 45 | + * ($CJMP_SDK_HOME/cjmp-tools/third_party/cangjie-android). | ||
| 46 | + */ | ||
| 47 | +export function resolveCangjieHome(): string { | ||
| 48 | + const override = process.env.CANGJIE_LSP_HOME; | ||
| 49 | + if (override) { | ||
| 50 | + return override; | ||
| 51 | + } | ||
| 52 | + const sdkHome = process.env.CJMP_SDK_HOME; | ||
| 53 | + if (!sdkHome) { | ||
| 54 | + throw new Error('CJMP_SDK_HOME must point to a real CJMP SDK (or set CANGJIE_LSP_HOME to the cangjie toolchain root).'); | ||
| 55 | + } | ||
| 56 | + return path.join(sdkHome, 'cjmp-tools', 'third_party', 'cangjie-android'); | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +/** Returns the LSPServer binary path inside the toolchain; throws when missing. */ | ||
| 60 | +export function resolveServerPath(cangjieHome: string): string { | ||
| 61 | + const serverName = process.platform === 'win32' ? 'LSPServer.exe' : 'LSPServer'; | ||
| 62 | + const serverPath = path.join(cangjieHome, 'tools', 'bin', serverName); | ||
| 63 | + if (!fs.existsSync(serverPath)) { | ||
| 64 | + throw new Error(`LSPServer not found at ${serverPath}`); | ||
| 65 | + } | ||
| 66 | + return serverPath; | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +/** | ||
| 70 | + * Realistic client capabilities for the handshake: the Cangjie server gates | ||
| 71 | + * features on the capabilities declared in initialize, and an empty object | ||
| 72 | + * makes hover/completion/highlight silently return null. | ||
| 73 | + */ | ||
| 74 | +export function buildClientCapabilities(): Record<string, unknown> { | ||
| 75 | + return { | ||
| 76 | + textDocument: { | ||
| 77 | + hover: { dynamicRegistration: true, contentFormat: ['markdown', 'plaintext'] }, | ||
| 78 | + definition: { dynamicRegistration: true, linkSupport: true }, | ||
| 79 | + references: { dynamicRegistration: true }, | ||
| 80 | + documentHighlight: { dynamicRegistration: true }, | ||
| 81 | + documentSymbol: { dynamicRegistration: true, hierarchicalDocumentSymbolSupport: true }, | ||
| 82 | + completion: { | ||
| 83 | + dynamicRegistration: true, | ||
| 84 | + completionItem: { snippetSupport: true, documentationFormat: ['markdown', 'plaintext'] }, | ||
| 85 | + }, | ||
| 86 | + semanticTokens: { | ||
| 87 | + dynamicRegistration: true, | ||
| 88 | + requests: { full: { delta: false } }, | ||
| 89 | + tokenTypes: [], | ||
| 90 | + tokenModifiers: [], | ||
| 91 | + formats: ['relative'], | ||
| 92 | + }, | ||
| 93 | + publishDiagnostics: { relatedInformation: true }, | ||
| 94 | + }, | ||
| 95 | + workspace: { workspaceFolders: true }, | ||
| 96 | + }; | ||
| 97 | +} | ||
| 98 | + | ||
| 99 | +/** Minimal stdio LSP client: Content-Length framing, request map, notification log. */ | ||
| 100 | +export class LspClient { | ||
| 101 | + stderr = ''; | ||
| 102 | + | ||
| 103 | + private readonly child: ChildProcess; | ||
| 104 | + private buffer: Buffer = Buffer.alloc(0); | ||
| 105 | + private readonly messages: LspJsonMessage[] = []; | ||
| 106 | + private readonly pending = new Map<number | string, PendingRequest>(); | ||
| 107 | + private nextId = 1; | ||
| 108 | + | ||
| 109 | + constructor(serverPath: string, args: readonly string[], env: NodeJS.ProcessEnv, cwd: string) { | ||
| 110 | + this.child = spawn(serverPath, args, { stdio: ['pipe', 'pipe', 'pipe'], env, cwd }); | ||
| 111 | + this.child.stdout?.on('data', (chunk: Buffer) => this.onData(chunk)); | ||
| 112 | + this.child.stderr?.on('data', (chunk: Buffer) => { | ||
| 113 | + this.stderr += chunk.toString(); | ||
| 114 | + }); | ||
| 115 | + this.child.on('exit', (code) => { | ||
| 116 | + for (const entry of this.pending.values()) { | ||
| 117 | + clearTimeout(entry.timer); | ||
| 118 | + entry.reject(new Error(`LSPServer exited with code ${code}. stderr: ${this.stderr.slice(0, 400)}`)); | ||
| 119 | + } | ||
| 120 | + this.pending.clear(); | ||
| 121 | + }); | ||
| 122 | + } | ||
| 123 | + | ||
| 124 | + private onData(chunk: Buffer): void { | ||
| 125 | + this.buffer = Buffer.concat([this.buffer, chunk]); | ||
| 126 | + while (true) { | ||
| 127 | + const headerEnd = this.buffer.indexOf('\r\n\r\n'); | ||
| 128 | + if (headerEnd === -1) { | ||
| 129 | + return; | ||
| 130 | + } | ||
| 131 | + const header = this.buffer.slice(0, headerEnd).toString(); | ||
| 132 | + const match = /Content-Length: (\d+)/i.exec(header); | ||
| 133 | + if (!match) { | ||
| 134 | + return; | ||
| 135 | + } | ||
| 136 | + const length = Number.parseInt(match[1], 10); | ||
| 137 | + if (this.buffer.length < headerEnd + 4 + length) { | ||
| 138 | + return; | ||
| 139 | + } | ||
| 140 | + const body = this.buffer.slice(headerEnd + 4, headerEnd + 4 + length).toString(); | ||
| 141 | + this.buffer = this.buffer.slice(headerEnd + 4 + length); | ||
| 142 | + let message: LspJsonMessage; | ||
| 143 | + try { | ||
| 144 | + message = JSON.parse(body) as LspJsonMessage; | ||
| 145 | + } catch { | ||
| 146 | + continue; | ||
| 147 | + } | ||
| 148 | + this.messages.push(message); | ||
| 149 | + if (message.id !== undefined && message.method === undefined) { | ||
| 150 | + const entry = this.pending.get(message.id); | ||
| 151 | + if (entry) { | ||
| 152 | + clearTimeout(entry.timer); | ||
| 153 | + this.pending.delete(message.id); | ||
| 154 | + entry.resolve(message); | ||
| 155 | + } | ||
| 156 | + } else if (message.method && message.id !== undefined) { | ||
| 157 | + // Server -> client request (e.g. window/workDoneProgress/create); answer with a null result. | ||
| 158 | + this.send({ jsonrpc: '2.0', id: message.id, result: null }); | ||
| 159 | + } | ||
| 160 | + } | ||
| 161 | + } | ||
| 162 | + | ||
| 163 | + private send(message: LspJsonMessage): void { | ||
| 164 | + const body = JSON.stringify(message); | ||
| 165 | + this.child.stdin?.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`); | ||
| 166 | + } | ||
| 167 | + | ||
| 168 | + /** Sends a request and resolves with the full response message. */ | ||
| 169 | + request(method: string, params?: unknown, timeoutMs: number = LSP_REQUEST_TIMEOUT_MS): Promise<LspJsonMessage> { | ||
| 170 | + const id = this.nextId++; | ||
| 171 | + const promise = new Promise<LspJsonMessage>((resolve, reject) => { | ||
| 172 | + const timer = setTimeout(() => { | ||
| 173 | + this.pending.delete(id); | ||
| 174 | + reject(new Error(`timeout waiting for ${method}`)); | ||
| 175 | + }, timeoutMs); | ||
| 176 | + this.pending.set(id, { resolve, reject, timer }); | ||
| 177 | + }); | ||
| 178 | + this.send({ jsonrpc: '2.0', id, method, params }); | ||
| 179 | + return promise; | ||
| 180 | + } | ||
| 181 | + | ||
| 182 | + notify(method: string, params?: unknown): void { | ||
| 183 | + this.send({ jsonrpc: '2.0', method, params }); | ||
| 184 | + } | ||
| 185 | + | ||
| 186 | + /** Waits for a notification matching the predicate; rejects on timeout. */ | ||
| 187 | + waitForNotification( | ||
| 188 | + method: string, | ||
| 189 | + predicate: (message: LspJsonMessage) => boolean, | ||
| 190 | + timeoutMs: number = LSP_NOTIFICATION_TIMEOUT_MS, | ||
| 191 | + ): Promise<LspJsonMessage> { | ||
| 192 | + return new Promise((resolve, reject) => { | ||
| 193 | + const startedAt = Date.now(); | ||
| 194 | + const timer = setInterval(() => { | ||
| 195 | + const found = this.messages.find((m) => m.method === method && predicate(m)); | ||
| 196 | + if (found) { | ||
| 197 | + clearInterval(timer); | ||
| 198 | + resolve(found); | ||
| 199 | + } else if (Date.now() - startedAt > timeoutMs) { | ||
| 200 | + clearInterval(timer); | ||
| 201 | + reject(new Error(`timeout waiting for ${method} notification`)); | ||
| 202 | + } | ||
| 203 | + }, 100); | ||
| 204 | + }); | ||
| 205 | + } | ||
| 206 | + | ||
| 207 | + /** Performs the LSP shutdown handshake; SIGKILLs the server as a fallback. */ | ||
| 208 | + async shutdown(): Promise<void> { | ||
| 209 | + try { | ||
| 210 | + await this.request('shutdown', undefined, 10_000); | ||
| 211 | + this.notify('exit'); | ||
| 212 | + } catch { | ||
| 213 | + // fall through to the kill below | ||
| 214 | + } | ||
| 215 | + await new Promise<void>((resolve) => { | ||
| 216 | + const timer = setTimeout(() => { | ||
| 217 | + this.child.kill('SIGKILL'); | ||
| 218 | + resolve(); | ||
| 219 | + }, 5_000); | ||
| 220 | + this.child.once('exit', () => { | ||
| 221 | + clearTimeout(timer); | ||
| 222 | + resolve(); | ||
| 223 | + }); | ||
| 224 | + }); | ||
| 225 | + } | ||
| 226 | +} | ||
| 227 | + | ||
| 228 | +/** Creates a minimal on-disk cangjie project the server agrees to analyze. */ | ||
| 229 | +export async function createProbeProject( | ||
| 230 | + greeterSource: string, | ||
| 231 | +): Promise<{ root: string; libDir: string; greeterPath: string; cleanup: () => Promise<void> }> { | ||
| 232 | + // realpath: os.tmpdir() is a symlink on macOS (/var -> /private/var) and the | ||
| 233 | + // server canonicalizes paths, so URIs must use the resolved location. | ||
| 234 | + const root = await fs.promises.realpath(await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cangjie-lsp-verify-'))); | ||
| 235 | + const libDir = path.join(root, 'lib'); | ||
| 236 | + await fs.promises.mkdir(libDir, { recursive: true }); | ||
| 237 | + await fs.promises.writeFile( | ||
| 238 | + path.join(root, 'project.conf'), | ||
| 239 | + '[project]\nname = lsp_verify\norganization = com.example\ntype = app\npackageName = lsp_verify\n', | ||
| 240 | + ); | ||
| 241 | + await fs.promises.writeFile( | ||
| 242 | + path.join(libDir, 'cjpm.toml'), | ||
| 243 | + '[package]\n cjc-version = "0.0.0"\n name = "lsp_verify"\n version = "0.0.1"\n', | ||
| 244 | + ); | ||
| 245 | + const greeterPath = path.join(libDir, 'greeter.cj'); | ||
| 246 | + await fs.promises.writeFile(greeterPath, greeterSource); | ||
| 247 | + return { | ||
| 248 | + root, | ||
| 249 | + libDir, | ||
| 250 | + greeterPath, | ||
| 251 | + cleanup: () => fs.promises.rm(root, { recursive: true, force: true }), | ||
| 252 | + }; | ||
| 253 | +} | ||
| @@ -0,0 +1,342 @@ | |||
| 1 | +/* | ||
| 2 | + * Copyright (c) 2026 Huawei Device Co., Ltd. | ||
| 3 | + * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 4 | + * you may not use this file except in compliance with the License. | ||
| 5 | + * You may obtain a copy of the License at | ||
| 6 | + * | ||
| 7 | + * http://www.apache.org/licenses/LICENSE-2.0 | ||
| 8 | + * | ||
| 9 | + * Unless required by applicable law or agreed to in writing, software | ||
| 10 | + * distributed under the License is distributed on an "AS IS" BASIS, | ||
| 11 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 12 | + * See the License for the specific language governing permissions and | ||
| 13 | + * limitations under the License. | ||
| 14 | + */ | ||
| 15 | + | ||
| 16 | +import * as assert from 'assert'; | ||
| 17 | +import * as fs from 'fs'; | ||
| 18 | +import * as path from 'path'; | ||
| 19 | +import * as vscode from 'vscode'; | ||
| 20 | + | ||
| 21 | +const CANGJIE_EXTENSION_NAME = 'cangjie'; | ||
| 22 | +const PROBE_FILE_NAME = 'lsp_e2e_probe.cj'; | ||
| 23 | + | ||
| 24 | +/** Probe source with known symbol positions; keep in sync with the assertions below. */ | ||
| 25 | +const PROBE_SOURCE = `func greet(name: String): String { | ||
| 26 | + return "hello " + name | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +main() { | ||
| 30 | + let msg = greet("cangjie") | ||
| 31 | + println(msg) | ||
| 32 | +} | ||
| 33 | +`; | ||
| 34 | +const GREETER_DEF_LINE = 0; // `func greet...` -> `greet` starts at character 5 | ||
| 35 | +const GREETER_CALL_LINE = 5; // ` let msg = greet("cangjie")` -> `greet` starts at character 14 | ||
| 36 | +const GREETER_CALL_CHARACTER = 16; | ||
| 37 | +const COMPLETION_LINE = 6; // inside `println(msg)` | ||
| 38 | +const COMPLETION_CHARACTER = 7; | ||
| 39 | + | ||
| 40 | +const SERVER_READY_TIMEOUT_MS = 180_000; | ||
| 41 | +const FEATURE_TIMEOUT_MS = 60_000; | ||
| 42 | +const POLL_INTERVAL_MS = 2_000; | ||
| 43 | + | ||
| 44 | +function workspaceRoot(): string { | ||
| 45 | + const folder = vscode.workspace.workspaceFolders?.[0]; | ||
| 46 | + assert.ok(folder, 'Expected the fixture workspace to be open'); | ||
| 47 | + return folder.uri.fsPath; | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +function probePath(): string { | ||
| 51 | + return path.join(workspaceRoot(), 'lib', PROBE_FILE_NAME); | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +function probeUri(): vscode.Uri { | ||
| 55 | + return vscode.Uri.file(probePath()); | ||
| 56 | +} | ||
| 57 | + | ||
| 58 | +/** Finds the installed Cangjie extension in the test host by its manifest name. */ | ||
| 59 | +function findCangjieExtension(): vscode.Extension<unknown> { | ||
| 60 | + const extension = vscode.extensions.all.find( | ||
| 61 | + (item) => String(item.packageJSON?.name).toLowerCase() === CANGJIE_EXTENSION_NAME, | ||
| 62 | + ); | ||
| 63 | + assert.ok(extension, 'Expected the Cangjie extension to be installed in the test host'); | ||
| 64 | + return extension; | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +/** | ||
| 68 | + * Retries an async probe until the predicate accepts its result or the deadline | ||
| 69 | + * passes; the language server needs time to index before features respond. | ||
| 70 | + */ | ||
| 71 | +async function eventually<T>( | ||
| 72 | + probe: () => PromiseLike<T>, | ||
| 73 | + predicate: (result: T) => boolean, | ||
| 74 | + description: string, | ||
| 75 | + timeoutMs: number = FEATURE_TIMEOUT_MS, | ||
| 76 | +): Promise<T> { | ||
| 77 | + const deadline = Date.now() + timeoutMs; | ||
| 78 | + let lastResult: T | undefined; | ||
| 79 | + let lastError: unknown; | ||
| 80 | + while (Date.now() < deadline) { | ||
| 81 | + try { | ||
| 82 | + lastResult = await probe(); | ||
| 83 | + if (predicate(lastResult)) { | ||
| 84 | + return lastResult; | ||
| 85 | + } | ||
| 86 | + } catch (error) { | ||
| 87 | + lastError = error; | ||
| 88 | + } | ||
| 89 | + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); | ||
| 90 | + } | ||
| 91 | + const detail = lastError instanceof Error ? lastError.message : JSON.stringify(lastResult); | ||
| 92 | + assert.fail(`Timed out waiting for ${description}; last result: ${detail}`); | ||
| 93 | +} | ||
| 94 | + | ||
| 95 | +/** Normalizes Location | LocationLink results into {uri, range}. */ | ||
| 96 | +function normalizeLocations(result: Array<vscode.Location | vscode.LocationLink>): Array<{ uri: vscode.Uri; range: vscode.Range }> { | ||
| 97 | + return result.map((item) => | ||
| 98 | + item instanceof vscode.Location | ||
| 99 | + ? { uri: item.uri, range: item.range } | ||
| 100 | + : { uri: item.targetUri, range: item.targetSelectionRange ?? item.targetRange }, | ||
| 101 | + ); | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +/** Extracts plain text from Hover contents (MarkdownString | MarkedString). */ | ||
| 105 | +function hoverText(hovers: vscode.Hover[]): string { | ||
| 106 | + return hovers | ||
| 107 | + .flatMap((hover) => hover.contents) | ||
| 108 | + .map((content) => (typeof content === 'string' ? content : String((content as vscode.MarkdownString).value ?? ''))) | ||
| 109 | + .join('\n'); | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +/** Flattens DocumentSymbol[] | SymbolInformation[] into symbol names. */ | ||
| 113 | +function symbolNames(result: Array<vscode.DocumentSymbol | vscode.SymbolInformation>): string[] { | ||
| 114 | + const names: string[] = []; | ||
| 115 | + const visit = (symbol: vscode.DocumentSymbol | vscode.SymbolInformation): void => { | ||
| 116 | + names.push(symbol.name); | ||
| 117 | + const children = (symbol as vscode.DocumentSymbol).children; | ||
| 118 | + if (Array.isArray(children)) { | ||
| 119 | + children.forEach(visit); | ||
| 120 | + } | ||
| 121 | + }; | ||
| 122 | + result.forEach(visit); | ||
| 123 | + return names; | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +suite('Cangjie LSP end-to-end', function () { | ||
| 127 | + this.timeout(SERVER_READY_TIMEOUT_MS + FEATURE_TIMEOUT_MS); | ||
| 128 | + | ||
| 129 | + suiteSetup(async () => { | ||
| 130 | + const extension = findCangjieExtension(); | ||
| 131 | + await extension.activate(); | ||
| 132 | + | ||
| 133 | + // The language server only analyzes files that exist on disk. | ||
| 134 | + fs.writeFileSync(probePath(), PROBE_SOURCE); | ||
| 135 | + const document = await vscode.workspace.openTextDocument(probePath()); | ||
| 136 | + await vscode.window.showTextDocument(document); | ||
| 137 | + | ||
| 138 | + // The probe file is open but suiteSetup does NOT block on the server | ||
| 139 | + // indexing it. Blocking here would abort the entire suite on the first | ||
| 140 | + // server-side failure, hiding whether the independent TextMate/grammar | ||
| 141 | + // checks still pass. Each server-backed case retries via eventually() and | ||
| 142 | + // surfaces its own failure, so the report shows precisely which capabilities | ||
| 143 | + // are broken against this real, unpatched extension. | ||
| 144 | + }); | ||
| 145 | + | ||
| 146 | + suiteTeardown(async () => { | ||
| 147 | + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); | ||
| 148 | + fs.rmSync(probePath(), { force: true }); | ||
| 149 | + }); | ||
| 150 | + | ||
| 151 | + /** | ||
| 152 | + * testActivatesCangjieExtensionWithTextMateGrammar | ||
| 153 | + * Test that the Cangjie extension activates and contributes a TextMate grammar for syntax highlighting. | ||
| 154 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 155 | + */ | ||
| 156 | + test('activates the Cangjie extension and contributes a TextMate grammar', async () => { | ||
| 157 | + const extension = findCangjieExtension(); | ||
| 158 | + assert.ok(extension.isActive, 'Expected the Cangjie extension to be active'); | ||
| 159 | + | ||
| 160 | + const grammars = (extension.packageJSON?.contributes?.grammars ?? []) as Array<{ language?: string; path?: string }>; | ||
| 161 | + const grammar = grammars.find((item) => String(item.language).toLowerCase() === CANGJIE_EXTENSION_NAME); | ||
| 162 | + assert.ok(grammar?.path, 'Expected a TextMate grammar contribution for the Cangjie language'); | ||
| 163 | + const grammarPath = path.join(extension.extensionPath, grammar.path); | ||
| 164 | + assert.ok(fs.existsSync(grammarPath), `Expected the grammar file to exist at ${grammarPath}`); | ||
| 165 | + | ||
| 166 | + const languages = await vscode.languages.getLanguages(); | ||
| 167 | + assert.ok(languages.includes('Cangjie'), 'Expected VS Code to know the Cangjie language'); | ||
| 168 | + }); | ||
| 169 | + | ||
| 170 | + /** | ||
| 171 | + * testAssociatesCjFilesWithCangjieLanguage | ||
| 172 | + * Test that .cj files open with the Cangjie language id so grammar-based highlighting applies. | ||
| 173 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 174 | + */ | ||
| 175 | + test('associates .cj files with the Cangjie language', async () => { | ||
| 176 | + const document = await vscode.workspace.openTextDocument(probeUri()); | ||
| 177 | + assert.strictEqual(document.languageId, 'Cangjie'); | ||
| 178 | + }); | ||
| 179 | + | ||
| 180 | + /** | ||
| 181 | + * testDefinitionJumpsFromCallSiteToDeclaration | ||
| 182 | + * Test that go-to-definition on the greet call site jumps to the func greet declaration. | ||
| 183 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 184 | + */ | ||
| 185 | + test('go-to-definition jumps from the call site to the declaration', async () => { | ||
| 186 | + const locations = await eventually( | ||
| 187 | + () => | ||
| 188 | + vscode.commands.executeCommand<Array<vscode.Location | vscode.LocationLink>>( | ||
| 189 | + 'vscode.executeDefinitionProvider', | ||
| 190 | + probeUri(), | ||
| 191 | + new vscode.Position(GREETER_CALL_LINE, GREETER_CALL_CHARACTER), | ||
| 192 | + ), | ||
| 193 | + (result) => normalizeLocations(result ?? []).some((loc) => loc.uri.fsPath === probePath() && loc.range.start.line === GREETER_DEF_LINE), | ||
| 194 | + 'definition of greet at the call site', | ||
| 195 | + ); | ||
| 196 | + const matches = normalizeLocations(locations).filter( | ||
| 197 | + (loc) => loc.uri.fsPath === probePath() && loc.range.start.line === GREETER_DEF_LINE, | ||
| 198 | + ); | ||
| 199 | + assert.ok(matches.length > 0, `Expected a definition on line ${GREETER_DEF_LINE}, got ${JSON.stringify(locations)}`); | ||
| 200 | + }); | ||
| 201 | + | ||
| 202 | + /** | ||
| 203 | + * testHoverShowsUserFunctionSignature | ||
| 204 | + * Test that hovering the greet call site shows the function signature documentation. | ||
| 205 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 206 | + */ | ||
| 207 | + test('hover shows the user-defined function signature', async () => { | ||
| 208 | + const hovers = await eventually( | ||
| 209 | + () => | ||
| 210 | + vscode.commands.executeCommand<vscode.Hover[]>( | ||
| 211 | + 'vscode.executeHoverProvider', | ||
| 212 | + probeUri(), | ||
| 213 | + new vscode.Position(GREETER_CALL_LINE, GREETER_CALL_CHARACTER), | ||
| 214 | + ), | ||
| 215 | + (result) => hoverText(result ?? []).includes('greet'), | ||
| 216 | + 'hover text for greet', | ||
| 217 | + ); | ||
| 218 | + const text = hoverText(hovers); | ||
| 219 | + assert.ok(text.includes('greet'), `Expected hover to mention greet, got: ${text}`); | ||
| 220 | + assert.ok(text.includes('String'), `Expected hover to show the signature, got: ${text}`); | ||
| 221 | + }); | ||
| 222 | + | ||
| 223 | + /** | ||
| 224 | + * testDocumentHighlightMarksAllOccurrences | ||
| 225 | + * Test that document highlight marks both the greet declaration and its call site. | ||
| 226 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 227 | + */ | ||
| 228 | + test('document highlight marks the declaration and the call site', async () => { | ||
| 229 | + const highlights = await eventually( | ||
| 230 | + () => | ||
| 231 | + vscode.commands.executeCommand<vscode.DocumentHighlight[]>( | ||
| 232 | + 'vscode.executeDocumentHighlights', | ||
| 233 | + probeUri(), | ||
| 234 | + new vscode.Position(GREETER_CALL_LINE, GREETER_CALL_CHARACTER), | ||
| 235 | + ), | ||
| 236 | + (result) => | ||
| 237 | + Array.isArray(result) && | ||
| 238 | + result.some((h) => h.range.start.line === GREETER_DEF_LINE) && | ||
| 239 | + result.some((h) => h.range.start.line === GREETER_CALL_LINE), | ||
| 240 | + 'document highlights for greet', | ||
| 241 | + ); | ||
| 242 | + assert.ok( | ||
| 243 | + highlights.some((h) => h.range.start.line === GREETER_DEF_LINE), | ||
| 244 | + `Expected a highlight on line ${GREETER_DEF_LINE}`, | ||
| 245 | + ); | ||
| 246 | + assert.ok( | ||
| 247 | + highlights.some((h) => h.range.start.line === GREETER_CALL_LINE), | ||
| 248 | + `Expected a highlight on line ${GREETER_CALL_LINE}`, | ||
| 249 | + ); | ||
| 250 | + }); | ||
| 251 | + | ||
| 252 | + /** | ||
| 253 | + * testSemanticTokensDriveSyntaxHighlighting | ||
| 254 | + * Test that the semantic tokens provider returns tokens so semantic syntax highlighting works. | ||
| 255 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 256 | + */ | ||
| 257 | + test('semantic tokens are returned for syntax highlighting', async () => { | ||
| 258 | + const tokens = await eventually( | ||
| 259 | + () => | ||
| 260 | + vscode.commands.executeCommand<vscode.SemanticTokens | undefined>( | ||
| 261 | + 'vscode.provideDocumentSemanticTokens', | ||
| 262 | + probeUri(), | ||
| 263 | + ), | ||
| 264 | + (result) => Boolean(result && result.data.length > 0), | ||
| 265 | + 'semantic tokens for the probe file', | ||
| 266 | + ); | ||
| 267 | + assert.ok(tokens && tokens.data.length > 0, 'Expected a non-empty semantic token stream'); | ||
| 268 | + | ||
| 269 | + const legend = await vscode.commands.executeCommand<vscode.SemanticTokensLegend | undefined>( | ||
| 270 | + 'vscode.provideDocumentSemanticTokensLegend', | ||
| 271 | + probeUri(), | ||
| 272 | + ); | ||
| 273 | + assert.ok(legend && legend.tokenTypes.length > 0, 'Expected a semantic tokens legend with token types'); | ||
| 274 | + }); | ||
| 275 | + | ||
| 276 | + /** | ||
| 277 | + * testDocumentSymbolsListUserFunctions | ||
| 278 | + * Test that the document symbol provider lists greet and main for the probe file. | ||
| 279 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 280 | + */ | ||
| 281 | + test('document symbols list the user-defined functions', async () => { | ||
| 282 | + const symbols = await vscode.commands.executeCommand<Array<vscode.DocumentSymbol | vscode.SymbolInformation>>( | ||
| 283 | + 'vscode.executeDocumentSymbolProvider', | ||
| 284 | + probeUri(), | ||
| 285 | + ); | ||
| 286 | + const names = symbolNames(symbols ?? []); | ||
| 287 | + assert.ok(names.some((name) => name.includes('greet')), `Expected greet in symbols, got: ${names.join(', ')}`); | ||
| 288 | + assert.ok(names.some((name) => name.includes('main')), `Expected main in symbols, got: ${names.join(', ')}`); | ||
| 289 | + }); | ||
| 290 | + | ||
| 291 | + /** | ||
| 292 | + * testCompletionOffersItemsInsideMain | ||
| 293 | + * Test that the completion provider offers items at a position inside main(). | ||
| 294 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 295 | + */ | ||
| 296 | + // e2e completion is a smoke check only: vscode.executeCompletionItemProvider | ||
| 297 | + // aggregates the LSP server AND VS Code's built-in word-based completion, so | ||
| 298 | + // when the Cangjie server is broken (direct mode) the word-based fallback | ||
| 299 | + // still returns greet/msg from the document and an "offers in-scope symbol" | ||
| 300 | + // assertion would be satisfied by the fallback — masking the server failure. | ||
| 301 | + // Server-side completion quality (scope-aware, not a global stdlib dump) is | ||
| 302 | + // verified at the protocol layer (test/lsp-protocol), which talks to the | ||
| 303 | + // server directly with no fallback. Do not assert content here. | ||
| 304 | + test('completion returns a list inside main()', async () => { | ||
| 305 | + const list = await eventually( | ||
| 306 | + () => | ||
| 307 | + vscode.commands.executeCommand<vscode.CompletionList | undefined>( | ||
| 308 | + 'vscode.executeCompletionItemProvider', | ||
| 309 | + probeUri(), | ||
| 310 | + new vscode.Position(COMPLETION_LINE, COMPLETION_CHARACTER), | ||
| 311 | + ), | ||
| 312 | + (result) => Boolean(result && result.items.length > 0), | ||
| 313 | + 'a non-empty completion list inside main()', | ||
| 314 | + ); | ||
| 315 | + assert.ok(list && list.items.length > 0, 'Expected a non-empty completion list'); | ||
| 316 | + }); | ||
| 317 | + | ||
| 318 | + /** | ||
| 319 | + * testNoDiagnosticsForValidProbeCode | ||
| 320 | + * Test that the language server reports no diagnostics for the syntactically valid probe file. | ||
| 321 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 322 | + */ | ||
| 323 | + test('reports no diagnostics for valid probe code', async () => { | ||
| 324 | + // Confirm the server is actually analyzing the file first; otherwise an | ||
| 325 | + // empty diagnostic list would be a false positive (server never ran). | ||
| 326 | + const symbols = await vscode.commands.executeCommand<Array<vscode.DocumentSymbol | vscode.SymbolInformation>>( | ||
| 327 | + 'vscode.executeDocumentSymbolProvider', | ||
| 328 | + probeUri(), | ||
| 329 | + ); | ||
| 330 | + assert.ok( | ||
| 331 | + Array.isArray(symbols) && symbolNames(symbols).some((n) => n.includes('greet')), | ||
| 332 | + 'Server did not analyze the probe file (no document symbols); cannot assert diagnostics meaningfully', | ||
| 333 | + ); | ||
| 334 | + const diagnostics = vscode.languages.getDiagnostics(probeUri()); | ||
| 335 | + const errors = diagnostics.filter((d) => d.severity === vscode.DiagnosticSeverity.Error); | ||
| 336 | + assert.deepStrictEqual( | ||
| 337 | + errors.map((d) => d.message), | ||
| 338 | + [], | ||
| 339 | + 'Expected no error diagnostics for valid probe code', | ||
| 340 | + ); | ||
| 341 | + }); | ||
| 342 | +}); | ||
| @@ -0,0 +1,237 @@ | |||
| 1 | +/* | ||
| 2 | + * Copyright (c) 2026 Huawei Device Co., Ltd. | ||
| 3 | + * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 4 | + * you may not use this file except in compliance with the License. | ||
| 5 | + * You may obtain a copy of the License at | ||
| 6 | + * | ||
| 7 | + * http://www.apache.org/licenses/LICENSE-2.0 | ||
| 8 | + * | ||
| 9 | + * Unless required by applicable law or agreed to in writing, software | ||
| 10 | + * distributed under the License is distributed on an "AS IS" BASIS, | ||
| 11 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 12 | + * See the License for the specific language governing permissions and | ||
| 13 | + * limitations under the License. | ||
| 14 | + */ | ||
| 15 | + | ||
| 16 | +/** | ||
| 17 | + * End-to-end verification of the Cangjie language server inside VS Code. | ||
| 18 | + * | ||
| 19 | + * The installed Cangjie extension is copied into the test host *byte-faithful* | ||
| 20 | + * (no patching), reproducing exactly what a user's VS Code does. The suite is | ||
| 21 | + * meant to EXPOSE real defects, not mask them: a red e2e is a real finding, | ||
| 22 | + * not a nuisance to paper over. The Mocha suite in test/lsp-e2e drives VS Code | ||
| 23 | + * built-in commands (vscode.executeDefinitionProvider, vscode.executeHoverProvider, | ||
| 24 | + * vscode.provideDocumentSemanticTokens, ...) against the fixture workspace and | ||
| 25 | + * asserts that syntax highlighting, go-to-definition, hover, etc. work. | ||
| 26 | + * | ||
| 27 | + * Prerequisites: | ||
| 28 | + * - The Cangjie extension installed for the current user | ||
| 29 | + * (~/.vscode/extensions/ide-innovation-lab.cangjie-*), or point | ||
| 30 | + * CANGJIE_VSCODE_EXTENSION_DIR at an unpacked extension directory. | ||
| 31 | + * - CJMP_SDK_HOME pointing at a CJMP SDK (the cangjie toolchain is resolved | ||
| 32 | + * as $CJMP_SDK_HOME/cjmp-tools/third_party/cangjie-android), or | ||
| 33 | + * CANGJIE_LSP_HOME pointing directly at the toolchain root. | ||
| 34 | + * | ||
| 35 | + * Known finding the suite is designed to surface (not hide): | ||
| 36 | + * With cangjie extension 1.1.0 + CJMP SDK 0.6.2 on macOS the server-backed | ||
| 37 | + * features (go-to-definition, hover, semantic tokens, document symbols, | ||
| 38 | + * completion) fail because the extension (1) spawns LSPServer without | ||
| 39 | + * passing CANGJIE_HOME, so the server cannot locate the standard library, | ||
| 40 | + * and (2) always sends an invalid stdLibPathOption pointing at a lib/src | ||
| 41 | + * that this SDK does not ship. Only the TextMate-grammar checks pass. | ||
| 42 | + * These are real extension/SDK defects; the protocol suite in | ||
| 43 | + * test/lsp-protocol confirms the raw server itself is healthy (it passes | ||
| 44 | + * when given a correct CANGJIE_HOME and no stdLibPathOption). | ||
| 45 | + * | ||
| 46 | + * Other knobs: | ||
| 47 | + * CANGJIE_E2E_VSCODE_VERSION — VS Code version for the test host | ||
| 48 | + * (default 1.130.0, verified working with @vscode/test-electron 3.1.0). | ||
| 49 | + * CANGJIE_E2E_TEST_GLOB — override the compiled test glob (debugging). | ||
| 50 | + */ | ||
| 51 | + | ||
| 52 | +import { runTests } from '@vscode/test-electron'; | ||
| 53 | +import { glob } from 'glob'; | ||
| 54 | +import JSON5 from 'json5'; | ||
| 55 | +import { cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; | ||
| 56 | +import { existsSync } from 'node:fs'; | ||
| 57 | +import os from 'node:os'; | ||
| 58 | +import path from 'node:path'; | ||
| 59 | +import process from 'node:process'; | ||
| 60 | +import { fileURLToPath } from 'node:url'; | ||
| 61 | + | ||
| 62 | +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); | ||
| 63 | +const extensionRunner = path.join(repoRoot, 'node_modules', '@vscode', 'test-cli', 'out', 'runner.cjs'); | ||
| 64 | +const workspaceFolder = path.join(repoRoot, 'test', 'fixtures', 'workspaces', 'app'); | ||
| 65 | +const extensionTestGlob = path.join(repoRoot, 'out', 'test', 'lsp-e2e', '**', '*.test.js'); | ||
| 66 | +const testUserDataDir = path.join(repoRoot, '.vscode-test', 'user-data-lsp-e2e'); | ||
| 67 | +const testExtensionsDir = path.join(repoRoot, '.vscode-test', 'extensions-lsp-e2e'); | ||
| 68 | +const fixtureSettingsPath = path.join(workspaceFolder, '.vscode', 'settings.json'); | ||
| 69 | + | ||
| 70 | +const CANGJIE_EXTENSION_PREFIX = 'ide-innovation-lab.cangjie-'; | ||
| 71 | + | ||
| 72 | +/** | ||
| 73 | + * Locates the locally installed Cangjie extension directory. | ||
| 74 | + * CANGJIE_VSCODE_EXTENSION_DIR wins; otherwise the newest | ||
| 75 | + * ide-innovation-lab.cangjie-* folder under ~/.vscode/extensions is used. | ||
| 76 | + */ | ||
| 77 | +async function resolveCangjieExtensionDir() { | ||
| 78 | + const override = process.env.CANGJIE_VSCODE_EXTENSION_DIR; | ||
| 79 | + if (override) { | ||
| 80 | + if (!existsSync(path.join(override, 'package.json'))) { | ||
| 81 | + throw new Error(`CANGJIE_VSCODE_EXTENSION_DIR does not contain a package.json: ${override}`); | ||
| 82 | + } | ||
| 83 | + return override; | ||
| 84 | + } | ||
| 85 | + const extensionsRoot = path.join(os.homedir(), '.vscode', 'extensions'); | ||
| 86 | + const entries = await readdir(extensionsRoot).catch(() => []); | ||
| 87 | + const candidates = entries | ||
| 88 | + .filter((entry) => entry.startsWith(CANGJIE_EXTENSION_PREFIX)) | ||
| 89 | + .sort((a, b) => compareVersions(a.slice(CANGJIE_EXTENSION_PREFIX.length), b.slice(CANGJIE_EXTENSION_PREFIX.length))); | ||
| 90 | + const newest = candidates[candidates.length - 1]; | ||
| 91 | + if (!newest) { | ||
| 92 | + throw new Error( | ||
| 93 | + `No Cangjie extension found under ${extensionsRoot}. ` + | ||
| 94 | + 'Install the Cangjie extension or set CANGJIE_VSCODE_EXTENSION_DIR.', | ||
| 95 | + ); | ||
| 96 | + } | ||
| 97 | + return path.join(extensionsRoot, newest); | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +/** Compares dotted version strings numerically, segment by segment. */ | ||
| 101 | +function compareVersions(a, b) { | ||
| 102 | + const pa = a.split('.').map((n) => Number.parseInt(n, 10) || 0); | ||
| 103 | + const pb = b.split('.').map((n) => Number.parseInt(n, 10) || 0); | ||
| 104 | + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { | ||
| 105 | + const diff = (pa[i] ?? 0) - (pb[i] ?? 0); | ||
| 106 | + if (diff !== 0) { | ||
| 107 | + return diff; | ||
| 108 | + } | ||
| 109 | + } | ||
| 110 | + return 0; | ||
| 111 | +} | ||
| 112 | + | ||
| 113 | +/** Resolves the cangjie toolchain root that contains tools/bin/LSPServer. */ | ||
| 114 | +function resolveCangjieHome() { | ||
| 115 | + const override = process.env.CANGJIE_LSP_HOME; | ||
| 116 | + if (override) { | ||
| 117 | + return override; | ||
| 118 | + } | ||
| 119 | + const sdkHome = process.env.CJMP_SDK_HOME; | ||
| 120 | + if (!sdkHome) { | ||
| 121 | + throw new Error('CJMP_SDK_HOME must point to a real CJMP SDK (or set CANGJIE_LSP_HOME to the cangjie toolchain root).'); | ||
| 122 | + } | ||
| 123 | + return path.join(sdkHome, 'cjmp-tools', 'third_party', 'cangjie-android'); | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +function assertServerBinary(cangjieHome) { | ||
| 127 | + const serverName = process.platform === 'win32' ? 'LSPServer.exe' : 'LSPServer'; | ||
| 128 | + const serverPath = path.join(cangjieHome, 'tools', 'bin', serverName); | ||
| 129 | + if (!existsSync(serverPath)) { | ||
| 130 | + throw new Error(`LSPServer not found at ${serverPath}`); | ||
| 131 | + } | ||
| 132 | + return serverPath; | ||
| 133 | +} | ||
| 134 | + | ||
| 135 | +/** | ||
| 136 | + * Points the fixture workspace at the cangjie toolchain via | ||
| 137 | + * CangjieSdkPath.CJNativeBackend before VS Code launches (the extension reads | ||
| 138 | + * the setting when it activates). Returns a restore function that puts the | ||
| 139 | + * original .vscode/settings.json content back byte-for-byte. | ||
| 140 | + */ | ||
| 141 | +async function writeWorkspaceSdkSetting(cangjieHome) { | ||
| 142 | + let originalRaw; | ||
| 143 | + if (existsSync(fixtureSettingsPath)) { | ||
| 144 | + originalRaw = await readFile(fixtureSettingsPath, 'utf-8'); | ||
| 145 | + } | ||
| 146 | + const settings = originalRaw ? JSON5.parse(originalRaw) : {}; | ||
| 147 | + settings['CangjieSdkPath.CJNativeBackend'] = cangjieHome; | ||
| 148 | + await mkdir(path.dirname(fixtureSettingsPath), { recursive: true }); | ||
| 149 | + await writeFile(fixtureSettingsPath, `${JSON.stringify(settings, null, 2)}\n`); | ||
| 150 | + | ||
| 151 | + return async () => { | ||
| 152 | + if (originalRaw === undefined) { | ||
| 153 | + await rm(fixtureSettingsPath, { force: true }); | ||
| 154 | + } else { | ||
| 155 | + await writeFile(fixtureSettingsPath, originalRaw); | ||
| 156 | + } | ||
| 157 | + }; | ||
| 158 | +} | ||
| 159 | + | ||
| 160 | +/** Collects the compiled lsp-e2e test files and builds the Mocha config for @vscode/test-cli. */ | ||
| 161 | +async function createTestOptions() { | ||
| 162 | + const pattern = process.env.CANGJIE_E2E_TEST_GLOB ?? extensionTestGlob; | ||
| 163 | + const files = await glob(pattern, { nodir: true }); | ||
| 164 | + if (files.length === 0) { | ||
| 165 | + throw new Error(`No lsp-e2e tests matched ${pattern}; run "npm run compile-tests" first.`); | ||
| 166 | + } | ||
| 167 | + return { | ||
| 168 | + mochaOpts: { | ||
| 169 | + ui: 'tdd', | ||
| 170 | + // The language server needs time to boot and index inside the test host. | ||
| 171 | + timeout: 240000, | ||
| 172 | + reporter: 'spec', | ||
| 173 | + }, | ||
| 174 | + colorDefault: process.stdout.isTTY, | ||
| 175 | + preload: [], | ||
| 176 | + files, | ||
| 177 | + }; | ||
| 178 | +} | ||
| 179 | + | ||
| 180 | +/** | ||
| 181 | + * Main flow: | ||
| 182 | + * 1) Recreate isolated user-data / extensions dirs for the lsp-e2e host. | ||
| 183 | + * 2) Copy the installed Cangjie extension into the test extensions dir, | ||
| 184 | + * byte-faithful (no patching) so the run reproduces a real user setup. | ||
| 185 | + * 3) Write CangjieSdkPath.CJNativeBackend into the fixture workspace settings. | ||
| 186 | + * 4) Launch the VS Code extension host on the fixture workspace and run the suite. | ||
| 187 | + * 5) Restore the original fixture settings. | ||
| 188 | + */ | ||
| 189 | +async function main() { | ||
| 190 | + const extensionDir = await resolveCangjieExtensionDir(); | ||
| 191 | + const cangjieHome = resolveCangjieHome(); | ||
| 192 | + const serverPath = assertServerBinary(cangjieHome); | ||
| 193 | + console.log(`Cangjie extension: ${extensionDir}`); | ||
| 194 | + console.log(`Cangjie toolchain: ${cangjieHome}`); | ||
| 195 | + console.log(`LSPServer binary: ${serverPath}`); | ||
| 196 | + | ||
| 197 | + await rm(testUserDataDir, { recursive: true, force: true }); | ||
| 198 | + await rm(testExtensionsDir, { recursive: true, force: true }); | ||
| 199 | + await mkdir(testUserDataDir, { recursive: true }); | ||
| 200 | + await mkdir(testExtensionsDir, { recursive: true }); | ||
| 201 | + const copiedExtensionDir = path.join(testExtensionsDir, path.basename(extensionDir)); | ||
| 202 | + await cp(extensionDir, copiedExtensionDir, { recursive: true }); | ||
| 203 | + | ||
| 204 | + const restoreSettings = await writeWorkspaceSdkSetting(cangjieHome); | ||
| 205 | + // The Cangjie extension writes editor.* keys into the workspace settings while | ||
| 206 | + // running; restore the fixture file even when the runner is interrupted. | ||
| 207 | + for (const signal of ['SIGINT', 'SIGTERM']) { | ||
| 208 | + process.once(signal, async () => { | ||
| 209 | + await restoreSettings().catch(() => {}); | ||
| 210 | + process.exit(130); | ||
| 211 | + }); | ||
| 212 | + } | ||
| 213 | + try { | ||
| 214 | + await runTests({ | ||
| 215 | + // Pin to a version verified working with @vscode/test-electron 3.1.0. | ||
| 216 | + version: process.env.CANGJIE_E2E_VSCODE_VERSION ?? '1.130.0', | ||
| 217 | + extensionDevelopmentPath: repoRoot, | ||
| 218 | + extensionTestsPath: extensionRunner, | ||
| 219 | + launchArgs: [ | ||
| 220 | + `--user-data-dir=${testUserDataDir}`, | ||
| 221 | + `--extensions-dir=${testExtensionsDir}`, | ||
| 222 | + workspaceFolder, | ||
| 223 | + ], | ||
| 224 | + extensionTestsEnv: { | ||
| 225 | + ELECTRON_RUN_AS_NODE: undefined, | ||
| 226 | + VSCODE_TEST_OPTIONS: JSON.stringify(await createTestOptions()), | ||
| 227 | + }, | ||
| 228 | + }); | ||
| 229 | + } finally { | ||
| 230 | + await restoreSettings(); | ||
| 231 | + } | ||
| 232 | +} | ||
| 233 | + | ||
| 234 | +main().catch((error) => { | ||
| 235 | + console.error(error); | ||
| 236 | + process.exit(1); | ||
| 237 | +}); | ||
| @@ -0,0 +1,356 @@ | |||
| 1 | +/* | ||
| 2 | + * Copyright (c) 2026 Huawei Device Co., Ltd. | ||
| 3 | + * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 4 | + * you may not use this file except in compliance with the License. | ||
| 5 | + * You may obtain a copy of the License at | ||
| 6 | + * | ||
| 7 | + * http://www.apache.org/licenses/LICENSE-2.0 | ||
| 8 | + * | ||
| 9 | + * Unless required by applicable law or agreed to in writing, software | ||
| 10 | + * distributed under the License is distributed on an "AS IS" BASIS, | ||
| 11 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 12 | + * See the License for the specific language governing permissions and | ||
| 13 | + * limitations under the License. | ||
| 14 | + */ | ||
| 15 | + | ||
| 16 | +import * as assert from 'assert'; | ||
| 17 | +import * as fs from 'fs'; | ||
| 18 | +import * as path from 'path'; | ||
| 19 | +import { pathToFileURL } from 'url'; | ||
| 20 | + | ||
| 21 | +import { | ||
| 22 | + buildClientCapabilities, | ||
| 23 | + createProbeProject, | ||
| 24 | + LspClient, | ||
| 25 | + LSP_SERVER_ARGS, | ||
| 26 | + resolveCangjieHome, | ||
| 27 | + resolveServerPath, | ||
| 28 | +} from '../helpers/lspClient'; | ||
| 29 | + | ||
| 30 | +/** Probe source with known symbol positions; keep in sync with the assertions below. */ | ||
| 31 | +const GREETER_SOURCE = `func greet(name: String): String { | ||
| 32 | + return "hello " + name | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +main() { | ||
| 36 | + let msg = greet("cangjie") | ||
| 37 | + println(msg) | ||
| 38 | +} | ||
| 39 | +`; | ||
| 40 | +const GREETER_DEF_LINE = 0; // `func greet...` -> `greet` spans characters 5..10 | ||
| 41 | +const GREETER_DEF_START_CHAR = 5; | ||
| 42 | +const GREETER_CALL_LINE = 5; // ` let msg = greet("cangjie")` -> `greet` spans characters 14..19 | ||
| 43 | +const GREETER_CALL_CHAR = 16; | ||
| 44 | +const GREETER_CALL_START_CHAR = 14; | ||
| 45 | +const COMPLETION_LINE = 6; // inside `println(msg)` | ||
| 46 | +const COMPLETION_CHAR = 7; | ||
| 47 | +const BROKEN_SOURCE = 'func broken( {\n let x = \n}\n'; | ||
| 48 | + | ||
| 49 | +interface LspLocation { | ||
| 50 | + uri: string; | ||
| 51 | + range: { start: { line: number; character: number } }; | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +interface InitializeResult { | ||
| 55 | + capabilities?: Record<string, unknown>; | ||
| 56 | + serverInfo?: { name?: string }; | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +/** Normalizes Location | Location[] | LocationLink[] into a flat array of {uri, range}. */ | ||
| 60 | +function normalizeLocations(result: unknown): LspLocation[] { | ||
| 61 | + if (!result) { | ||
| 62 | + return []; | ||
| 63 | + } | ||
| 64 | + const list = Array.isArray(result) ? result : [result]; | ||
| 65 | + return list.map((item) => { | ||
| 66 | + const link = item as { targetUri?: string; targetSelectionRange?: LspLocation['range']; targetRange?: LspLocation['range'] }; | ||
| 67 | + if (link.targetUri) { | ||
| 68 | + return { uri: link.targetUri, range: link.targetSelectionRange ?? (link.targetRange as LspLocation['range']) }; | ||
| 69 | + } | ||
| 70 | + return item as LspLocation; | ||
| 71 | + }); | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +/** Extracts hover text from MarkupContent | MarkedString | MarkedString[]. */ | ||
| 75 | +function hoverText(result: unknown): string { | ||
| 76 | + const contents = (result as { contents?: unknown } | null)?.contents; | ||
| 77 | + if (!contents) { | ||
| 78 | + return ''; | ||
| 79 | + } | ||
| 80 | + if (typeof contents === 'string') { | ||
| 81 | + return contents; | ||
| 82 | + } | ||
| 83 | + if (Array.isArray(contents)) { | ||
| 84 | + return contents.map((c) => (typeof c === 'string' ? c : String((c as { value?: string }).value ?? ''))).join('\n'); | ||
| 85 | + } | ||
| 86 | + return String((contents as { value?: string }).value ?? ''); | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +/** Flattens DocumentSymbol[] | SymbolInformation[] into a list of names. */ | ||
| 90 | +function symbolNames(result: unknown): string[] { | ||
| 91 | + if (!Array.isArray(result)) { | ||
| 92 | + return []; | ||
| 93 | + } | ||
| 94 | + const names: string[] = []; | ||
| 95 | + const visit = (symbol: { name?: string; children?: unknown[] }): void => { | ||
| 96 | + if (symbol.name) { | ||
| 97 | + names.push(symbol.name); | ||
| 98 | + } | ||
| 99 | + if (Array.isArray(symbol.children)) { | ||
| 100 | + symbol.children.forEach((child) => visit(child as { name?: string; children?: unknown[] })); | ||
| 101 | + } | ||
| 102 | + }; | ||
| 103 | + result.forEach((symbol) => visit(symbol as { name?: string; children?: unknown[] })); | ||
| 104 | + return names; | ||
| 105 | +} | ||
| 106 | + | ||
| 107 | +suite('Cangjie LSP protocol', function () { | ||
| 108 | + this.timeout(240_000); | ||
| 109 | + | ||
| 110 | + const clientHolder: { client?: LspClient } = {}; | ||
| 111 | + let greeterUri = ''; | ||
| 112 | + let projectLibDir = ''; | ||
| 113 | + let cleanupProject: () => Promise<void> = async () => {}; | ||
| 114 | + let initializeResult: InitializeResult = {}; | ||
| 115 | + | ||
| 116 | + function client(): LspClient { | ||
| 117 | + assert.ok(clientHolder.client, 'Expected the LSP client to be started in suiteSetup'); | ||
| 118 | + return clientHolder.client; | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + suiteSetup(async () => { | ||
| 122 | + const cangjieHome = resolveCangjieHome(); | ||
| 123 | + const serverPath = resolveServerPath(cangjieHome); | ||
| 124 | + const project = await createProbeProject(GREETER_SOURCE); | ||
| 125 | + cleanupProject = project.cleanup; | ||
| 126 | + projectLibDir = project.libDir; | ||
| 127 | + const rootUri = pathToFileURL(project.root).href; | ||
| 128 | + greeterUri = pathToFileURL(project.greeterPath).href; | ||
| 129 | + | ||
| 130 | + const env = { | ||
| 131 | + ...process.env, | ||
| 132 | + CANGJIE_HOME: cangjieHome, | ||
| 133 | + PATH: `${path.join(cangjieHome, 'bin')}${path.delimiter}${path.join(cangjieHome, 'tools', 'bin')}${path.delimiter}${process.env.PATH}`, | ||
| 134 | + }; | ||
| 135 | + clientHolder.client = new LspClient(serverPath, LSP_SERVER_ARGS, env, project.root); | ||
| 136 | + | ||
| 137 | + const initResponse = await client().request('initialize', { | ||
| 138 | + processId: process.pid, | ||
| 139 | + rootUri, | ||
| 140 | + capabilities: buildClientCapabilities(), | ||
| 141 | + workspaceFolders: [{ uri: rootUri, name: 'lsp_verify' }], | ||
| 142 | + }); | ||
| 143 | + initializeResult = (initResponse.result ?? {}) as InitializeResult; | ||
| 144 | + | ||
| 145 | + client().notify('initialized', {}); | ||
| 146 | + client().notify('textDocument/didOpen', { | ||
| 147 | + textDocument: { uri: greeterUri, languageId: 'Cangjie', version: 1, text: GREETER_SOURCE }, | ||
| 148 | + }); | ||
| 149 | + // The server pushes diagnostics once it has analyzed the opened file. | ||
| 150 | + await client().waitForNotification( | ||
| 151 | + 'textDocument/publishDiagnostics', | ||
| 152 | + (m) => (m.params as { uri?: string } | undefined)?.uri === greeterUri, | ||
| 153 | + ); | ||
| 154 | + }); | ||
| 155 | + | ||
| 156 | + suiteTeardown(async () => { | ||
| 157 | + await clientHolder.client?.shutdown(); | ||
| 158 | + await cleanupProject(); | ||
| 159 | + }); | ||
| 160 | + | ||
| 161 | + /** | ||
| 162 | + * testInitializeRespondsWithCangjieServerInfo | ||
| 163 | + * Test that initialize responds with Cangjie server info. | ||
| 164 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 165 | + */ | ||
| 166 | + test('initialize responds with Cangjie server info', async () => { | ||
| 167 | + const name = initializeResult.serverInfo?.name ?? ''; | ||
| 168 | + assert.ok(name.toLowerCase().includes('cangjie'), `Expected a Cangjie server, got: ${name}`); | ||
| 169 | + }); | ||
| 170 | + | ||
| 171 | + /** | ||
| 172 | + * testInitializeAdvertisesVerifiedCapabilities | ||
| 173 | + * Test that initialize advertises definition/hover/highlight/semanticTokens/symbol/completion providers. | ||
| 174 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 175 | + */ | ||
| 176 | + test('initialize advertises the capabilities under verification', async () => { | ||
| 177 | + const capabilities = initializeResult.capabilities ?? {}; | ||
| 178 | + assert.strictEqual(capabilities.definitionProvider, true, 'definitionProvider'); | ||
| 179 | + assert.strictEqual(capabilities.hoverProvider, true, 'hoverProvider'); | ||
| 180 | + assert.strictEqual(capabilities.documentHighlightProvider, true, 'documentHighlightProvider'); | ||
| 181 | + assert.ok(capabilities.semanticTokensProvider, 'semanticTokensProvider'); | ||
| 182 | + assert.strictEqual(capabilities.documentSymbolProvider, true, 'documentSymbolProvider'); | ||
| 183 | + assert.ok(capabilities.completionProvider, 'completionProvider'); | ||
| 184 | + }); | ||
| 185 | + | ||
| 186 | + /** | ||
| 187 | + * testPublishesEmptyDiagnosticsForValidCode | ||
| 188 | + * Test that the server publishes empty diagnostics for syntactically valid code. | ||
| 189 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 190 | + */ | ||
| 191 | + test('publishes empty diagnostics for valid code', async () => { | ||
| 192 | + // suiteSetup already waited for the first publishDiagnostics of greeter.cj; | ||
| 193 | + // request the current state again via a no-op didOpen to assert contents. | ||
| 194 | + const notification = await client().waitForNotification( | ||
| 195 | + 'textDocument/publishDiagnostics', | ||
| 196 | + (m) => (m.params as { uri?: string } | undefined)?.uri === greeterUri, | ||
| 197 | + 5_000, | ||
| 198 | + ); | ||
| 199 | + const diagnostics = (notification.params as { diagnostics?: unknown[] }).diagnostics; | ||
| 200 | + assert.ok(Array.isArray(diagnostics), 'Expected a diagnostics array'); | ||
| 201 | + assert.deepStrictEqual(diagnostics, [], `Expected no diagnostics, got: ${JSON.stringify(diagnostics)}`); | ||
| 202 | + }); | ||
| 203 | + | ||
| 204 | + /** | ||
| 205 | + * testDefinitionJumpsFromCallSiteToDeclaration | ||
| 206 | + * Test that textDocument/definition jumps from the greet call site to its declaration. | ||
| 207 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 208 | + */ | ||
| 209 | + test('definition jumps from the call site to the declaration', async () => { | ||
| 210 | + const response = await client().request('textDocument/definition', { | ||
| 211 | + textDocument: { uri: greeterUri }, | ||
| 212 | + position: { line: GREETER_CALL_LINE, character: GREETER_CALL_CHAR }, | ||
| 213 | + }); | ||
| 214 | + const locations = normalizeLocations(response.result); | ||
| 215 | + assert.ok( | ||
| 216 | + locations.some( | ||
| 217 | + (loc) => | ||
| 218 | + loc.uri === greeterUri && | ||
| 219 | + loc.range.start.line === GREETER_DEF_LINE && | ||
| 220 | + loc.range.start.character === GREETER_DEF_START_CHAR, | ||
| 221 | + ), | ||
| 222 | + `Expected a definition at line ${GREETER_DEF_LINE} char ${GREETER_DEF_START_CHAR}, got: ${JSON.stringify(locations)}`, | ||
| 223 | + ); | ||
| 224 | + }); | ||
| 225 | + | ||
| 226 | + /** | ||
| 227 | + * testHoverReturnsFunctionSignature | ||
| 228 | + * Test that textDocument/hover returns the greet function signature in markdown. | ||
| 229 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 230 | + */ | ||
| 231 | + test('hover returns the function signature', async () => { | ||
| 232 | + const response = await client().request('textDocument/hover', { | ||
| 233 | + textDocument: { uri: greeterUri }, | ||
| 234 | + position: { line: GREETER_CALL_LINE, character: GREETER_CALL_CHAR }, | ||
| 235 | + }); | ||
| 236 | + const text = hoverText(response.result); | ||
| 237 | + assert.ok(text.includes('greet'), `Expected hover to mention greet, got: ${text}`); | ||
| 238 | + assert.ok(text.includes('String'), `Expected hover to show the signature, got: ${text}`); | ||
| 239 | + }); | ||
| 240 | + | ||
| 241 | + /** | ||
| 242 | + * testDocumentHighlightMarksAllOccurrences | ||
| 243 | + * Test that textDocument/documentHighlight marks both the greet declaration and the call site. | ||
| 244 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 245 | + */ | ||
| 246 | + test('documentHighlight marks the declaration and the call site', async () => { | ||
| 247 | + const response = await client().request('textDocument/documentHighlight', { | ||
| 248 | + textDocument: { uri: greeterUri }, | ||
| 249 | + position: { line: GREETER_CALL_LINE, character: GREETER_CALL_CHAR }, | ||
| 250 | + }); | ||
| 251 | + const highlights = (response.result ?? []) as Array<{ range: { start: { line: number; character: number } } }>; | ||
| 252 | + assert.ok(Array.isArray(highlights), 'Expected a highlights array'); | ||
| 253 | + assert.ok( | ||
| 254 | + highlights.some((h) => h.range.start.line === GREETER_DEF_LINE && h.range.start.character === GREETER_DEF_START_CHAR), | ||
| 255 | + `Expected a highlight at the declaration, got: ${JSON.stringify(highlights)}`, | ||
| 256 | + ); | ||
| 257 | + assert.ok( | ||
| 258 | + highlights.some((h) => h.range.start.line === GREETER_CALL_LINE && h.range.start.character === GREETER_CALL_START_CHAR), | ||
| 259 | + `Expected a highlight at the call site, got: ${JSON.stringify(highlights)}`, | ||
| 260 | + ); | ||
| 261 | + }); | ||
| 262 | + | ||
| 263 | + /** | ||
| 264 | + * testSemanticTokensReturnTokenStream | ||
| 265 | + * Test that textDocument/semanticTokens/full returns a non-empty token stream for syntax highlighting. | ||
| 266 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 267 | + */ | ||
| 268 | + test('semanticTokens returns a non-empty token stream', async () => { | ||
| 269 | + const response = await client().request('textDocument/semanticTokens/full', { | ||
| 270 | + textDocument: { uri: greeterUri }, | ||
| 271 | + }); | ||
| 272 | + const data = (response.result as { data?: number[] } | null)?.data; | ||
| 273 | + assert.ok(Array.isArray(data) && data.length > 0, 'Expected a non-empty semantic token stream'); | ||
| 274 | + assert.strictEqual(data.length % 5, 0, 'Token data must be a multiple of 5 (deltaLine, deltaChar, length, type, modifiers)'); | ||
| 275 | + }); | ||
| 276 | + | ||
| 277 | + /** | ||
| 278 | + * testDocumentSymbolListsUserFunctions | ||
| 279 | + * Test that textDocument/documentSymbol lists greet and main. | ||
| 280 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 281 | + */ | ||
| 282 | + test('documentSymbol lists greet and main', async () => { | ||
| 283 | + const response = await client().request('textDocument/documentSymbol', { | ||
| 284 | + textDocument: { uri: greeterUri }, | ||
| 285 | + }); | ||
| 286 | + const names = symbolNames(response.result); | ||
| 287 | + assert.ok(names.some((n) => n.includes('greet')), `Expected greet in symbols, got: ${names.join(', ')}`); | ||
| 288 | + assert.ok(names.some((n) => n.includes('main')), `Expected main in symbols, got: ${names.join(', ')}`); | ||
| 289 | + }); | ||
| 290 | + | ||
| 291 | + /** | ||
| 292 | + * testCompletionReturnsItems | ||
| 293 | + * Test that textDocument/completion returns items at a position inside main(). | ||
| 294 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 295 | + */ | ||
| 296 | + test('completion offers in-scope symbols', async () => { | ||
| 297 | + const response = await client().request('textDocument/completion', { | ||
| 298 | + textDocument: { uri: greeterUri }, | ||
| 299 | + position: { line: COMPLETION_LINE, character: COMPLETION_CHAR }, | ||
| 300 | + }); | ||
| 301 | + const result = response.result as { items?: Array<{ label?: string }> } | Array<{ label?: string }> | null; | ||
| 302 | + const items = Array.isArray(result) ? result : (result?.items ?? []); | ||
| 303 | + const labels = items.map((it) => String(it.label ?? '')); | ||
| 304 | + // A useful completion must surface in-scope symbols (the local `msg` or the | ||
| 305 | + // same-file function `greet`), not just a global stdlib dump. The Cangjie | ||
| 306 | + // server currently returns only stdlib globals here (print/eprintln/...); | ||
| 307 | + // this assertion exposes that it does not do scope-aware completion. | ||
| 308 | + assert.ok( | ||
| 309 | + labels.some((l) => l.includes('greet')) || labels.some((l) => l.includes('msg')), | ||
| 310 | + `Expected completion to offer an in-scope symbol (greet/msg); got ${labels.length} items: ${labels.slice(0, 10).join(', ')}${labels.length > 10 ? ' ...' : ''}`, | ||
| 311 | + ); | ||
| 312 | + }); | ||
| 313 | + | ||
| 314 | + /** | ||
| 315 | + * testReferencesKnownLimitation | ||
| 316 | + * Test that textDocument/references returns occurrences; skipped on the 0.5.1 SDK where the server returns null (known limitation). | ||
| 317 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 318 | + */ | ||
| 319 | + test('references returns occurrences (known limitation on SDK 0.5.1)', async function () { | ||
| 320 | + const response = await client().request('textDocument/references', { | ||
| 321 | + textDocument: { uri: greeterUri }, | ||
| 322 | + position: { line: GREETER_CALL_LINE, character: GREETER_CALL_CHAR }, | ||
| 323 | + context: { includeDeclaration: true }, | ||
| 324 | + }); | ||
| 325 | + if (!Array.isArray(response.result) || response.result.length === 0) { | ||
| 326 | + // The server advertises referencesProvider but returns null on this SDK build. | ||
| 327 | + this.skip(); | ||
| 328 | + return; | ||
| 329 | + } | ||
| 330 | + assert.ok(response.result.length > 0); | ||
| 331 | + }); | ||
| 332 | + | ||
| 333 | + /** | ||
| 334 | + * testPublishesDiagnosticsForBrokenCode | ||
| 335 | + * Test that the server publishes diagnostics for a syntactically broken file. | ||
| 336 | + * {Promise<void>} - Returns a Promise indicating the async test assertions have completed. | ||
| 337 | + */ | ||
| 338 | + test('publishes diagnostics for broken code', async () => { | ||
| 339 | + const brokenPath = path.join(projectLibDir, 'broken.cj'); | ||
| 340 | + const brokenUri = pathToFileURL(brokenPath).href; | ||
| 341 | + fs.writeFileSync(brokenPath, BROKEN_SOURCE); | ||
| 342 | + client().notify('textDocument/didOpen', { | ||
| 343 | + textDocument: { uri: brokenUri, languageId: 'Cangjie', version: 1, text: BROKEN_SOURCE }, | ||
| 344 | + }); | ||
| 345 | + const notification = await client().waitForNotification( | ||
| 346 | + 'textDocument/publishDiagnostics', | ||
| 347 | + (m) => { | ||
| 348 | + const params = m.params as { uri?: string; diagnostics?: unknown[] } | undefined; | ||
| 349 | + return params?.uri === brokenUri && Array.isArray(params.diagnostics) && params.diagnostics.length > 0; | ||
| 350 | + }, | ||
| 351 | + ); | ||
| 352 | + const diagnostics = (notification.params as { diagnostics: Array<{ message?: string }> }).diagnostics; | ||
| 353 | + assert.ok(diagnostics.length > 0, 'Expected diagnostics for broken code'); | ||
| 354 | + assert.ok(diagnostics[0].message, 'Expected the diagnostic to carry a message'); | ||
| 355 | + }); | ||
| 356 | +}); | ||
| @@ -0,0 +1,79 @@ | |||
| 1 | +# Cangjie LSP 测试报告 | ||
| 2 | + | ||
| 3 | +两层套件互补:**protocol**(`npm run test:lsp:protocol`,直接握手 LSPServer)验证服务端本身能力;**e2e direct**(`npm run test:lsp:e2e`,VS Code 宿主加载真实 Cangjie 插件、不打补丁)验证用户实际链路。 | ||
| 4 | + | ||
| 5 | +**环境**:CJMP SDK `cjmp-sdk-mac-arm64-0.6.2-release`、Cangjie 插件 `ide-innovation-lab.cangjie-1.1.0`、macOS arm64、VS Code 1.130.0、@vscode/test-electron 3.1.0。 | ||
| 6 | + | ||
| 7 | +**探针源码**(两层共用,符号位置已标注): | ||
| 8 | + | ||
| 9 | +```cangjie | ||
| 10 | +func greet(name: String): String { // line 0, greet 声明: char 5..9 | ||
| 11 | + return "hello " + name | ||
| 12 | +} | ||
| 13 | +main() { | ||
| 14 | + let msg = greet("cangjie") // line 5, greet 调用: char 14..18, 光标 char 16 | ||
| 15 | + println(msg) // line 6, 补全位置: char 7 | ||
| 16 | +} | ||
| 17 | +``` | ||
| 18 | + | ||
| 19 | +## 1. protocol 套件(11 项):实测 9 通过 + 1 跳过 + 1 失败 | ||
| 20 | + | ||
| 21 | +| # | 用例 | 测试内容 | 关键实际返回 | 结论 | | ||
| 22 | +|---|---|---|---|---| | ||
| 23 | +| 1 | initialize server info | 入口验证:握手确认对端为 Cangjie server | `{"name":"Cangjie language server","version":"1.0"}` | ✔ | | ||
| 24 | +| 2 | capabilities | initialize 声明受验能力(definition/hover/highlight/semanticTokens/symbol/completion) | 全部声明 | ✔ | | ||
| 25 | +| 3 | 空诊断(有效代码) | 诊断正确性基线:合法代码不误报 | `[]` | ✔ | | ||
| 26 | +| 4 | definition | 跳转到定义(对应 VS Code F12) | range 精准指向声明 line 0 char 5..10 | ✔ | | ||
| 27 | +| 5 | hover | 悬停显示函数签名 | `internal func greet(name: String): String` | ✔ | | ||
| 28 | +| 6 | documentHighlight | 高亮同名出现处 | 声明 line 0 + 调用 line 5 两处 | ✔ | | ||
| 29 | +| 7 | semanticTokens | 语义着色,仅验证非空+格式(不验证类型) | data 45 项(9 token),长度为 5 的倍数 | ✔ | | ||
| 30 | +| 8 | documentSymbol | 文档大纲(Outline) | `greet(String): String`、`main(): Int64` | ✔ | | ||
| 31 | +| 9 | completion | 补全须含上下文符号(msg/greet),防 length>0 假性通过 | 44 项全为标准库全局符号,**不含 greet/msg** | ✘ 暴露服务端不做作用域补全 | | ||
| 32 | +| 10 | references | 查找所有引用 | `null` | ⊘ skip(known limitation) | | ||
| 33 | +| 11 | 错误诊断 | 诊断敏感性基线:错误代码必报 | 非空,含 message | ✔ | | ||
| 34 | + | ||
| 35 | +**结论**:服务端跳转/悬停/高亮/语义token/符号/诊断能力本身正常;补全不做上下文过滤是服务端真实短板。 | ||
| 36 | + | ||
| 37 | +## 2. e2e direct 套件(9 项):实测 4 通过 + 5 失败 | ||
| 38 | + | ||
| 39 | +> 前提:插件 1.1.0 在 macOS 下 spawn LSPServer 时不传 `CANGJIE_HOME`、且总发无效 `stdLibPathOption`(SDK 0.6.2 无 `lib/src`),服务端无法定位标准库,语义结果全 null。 | ||
| 40 | + | ||
| 41 | +| # | 用例 | 测试内容 | 关键实际返回 | 结论 | | ||
| 42 | +|---|---|---|---|---| | ||
| 43 | +| 1 | 插件激活+TextMate 文法 | e2e 入口,不依赖服务端 | 激活、文法存在、语言注册 | ✔ | | ||
| 44 | +| 2 | .cj 语言关联 | languageId 为 Cangjie | `Cangjie` | ✔ | | ||
| 45 | +| 3 | 转到定义 | executeDefinitionProvider | `Timed out; last result: []` | ✘ 如实暴露不可用 | | ||
| 46 | +| 4 | 悬停 | executeHoverProvider | `Timed out; last result: []` | ✘ | | ||
| 47 | +| 5 | 高亮同名处 | executeDocumentHighlights | 断言被满足 | ✔ 但系 VS Code word-based 兜底,假性通过 | | ||
| 48 | +| 6 | 语义 token | provideDocumentSemanticTokens | `Timed out; last result: undefined` | ✘ 无兜底,如实暴露 | | ||
| 49 | +| 7 | 文档符号 | executeDocumentSymbolProvider | 空,greet 不在 | ✘ | | ||
| 50 | +| 8 | 补全 smoke check | executeCompletionItemProvider | 非空 | ✔ 但系 VS Code 兜底;服务端补全质量由 protocol 第 9 项验证 | | ||
| 51 | +| 9 | 有效代码无诊断 | 前置先确认服务端已分析(防"未工作→无诊断"假阳性) | 前置失败:Server did not analyze | ✘ 如实暴露服务端未工作 | | ||
| 52 | + | ||
| 53 | +**结论**:真实 VS Code 环境下跳转/悬停/语义高亮/符号/诊断 5 项能力全部失效(插件两缺陷所致);文法高亮正常;补全/高亮的"通过"是 VS Code 兜底假性。 | ||
| 54 | + | ||
| 55 | +## 3. 补充对照:completion 在不同位置的实际返回(探针) | ||
| 56 | + | ||
| 57 | +| 触发位置 | 实际返回 | 含 greet/msg | | ||
| 58 | +|---|---|---| | ||
| 59 | +| `println(msg)` 内(标识符内) | 44 项标准库全局符号 | 否 | | ||
| 60 | +| `let x = ` 后(典型表达式起始) | **0 项** | 否 | | ||
| 61 | + | ||
| 62 | +两种位置均不返回上下文符号,证实服务端补全不具备作用域分析能力。 | ||
| 63 | + | ||
| 64 | +## 4. 汇总与问题清单 | ||
| 65 | + | ||
| 66 | +| 套件 | 通过 | 失败 | 跳过 | | ||
| 67 | +|---|---|---|---| | ||
| 68 | +| protocol(直接服务端) | 9 | 1(补全短板) | 1(references) | | ||
| 69 | +| e2e direct(VS Code 集成) | 4(含 2 项 VS Code 兜底假性) | 5(服务端语义层失效) | 0 | | ||
| 70 | + | ||
| 71 | +| 级别 | 问题 | 证据 | | ||
| 72 | +|---|---|---| | ||
| 73 | +| 🔴 插件缺陷 | 启动 LSPServer 不传 `CANGJIE_HOME`,服务端找不到标准库 | e2e 5 项失败;protocol 对照(正确传参则 9/10 通过) | | ||
| 74 | +| 🔴 插件缺陷 | 总发无效 `stdLibPathOption`(SDK 无 `lib/src`,getStdLibPath 回退路径亦非标准库) | 同上;bundle 实现反混淆 + SDK 目录实测 | | ||
| 75 | +| 🔴 服务端短板 | 补全不做作用域过滤 | protocol 第 9 项失败 + 探针对照 | | ||
| 76 | +| 🟡 e2e 层局限 | VS Code word-based 兜底掩盖补全/高亮失效 | e2e 第 5/8 项假性通过 | | ||
| 77 | +| 🟡 测试盲区 | references 声明 provider 但返回 null | protocol 第 10 项 skip | | ||
| 78 | + | ||
| 79 | +**下一步**:向 Cangjie 插件方反馈两处缺陷(修复后 e2e direct 预期转绿);服务端补全与 references 短板需服务端侧评估。 | ||