zed-ets-language-server 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -6,7 +6,8 @@ import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { logger } from './lib/logger.js';
8
8
  import { parse } from './lib/data-parser.js';
9
- import { listHelperPaths } from './lib/lib-expander.js'
9
+ import { listHelperPaths } from './lib/lib-expander.js';
10
+ import { resolveHmsSdkPath, resolveOhosSdkPath } from './lib/sdk-discovery.js';
10
11
 
11
12
  // ETS language server path, passed by Rust extension process through environment variable
12
13
  const etsLangServerPath = process.env.ETS_LANG_SERVER;
@@ -37,12 +38,32 @@ function ensurePlaceholderSdk() {
37
38
 
38
39
  function detectTsdk() {
39
40
  if (!etsLangServerPath) return undefined;
40
- const serverBinDir = path.dirname(etsLangServerPath);
41
- const candidates = [
42
- path.join(serverBinDir, '..', '..', '..', 'ohos-typescript', 'lib'),
43
- path.join(serverBinDir, '..', 'node_modules', 'ohos-typescript', 'lib'),
44
- ];
45
- return candidates.find((dir) => fs.existsSync(path.join(dir, 'typescript.js')));
41
+ // The extension installs ohos-typescript next to @arkts/language-server:
42
+ // <work dir>/node_modules/ohos-typescript/lib
43
+ // <work dir>/node_modules/@arkts/language-server/bin/ets-language-server.js
44
+ // Walk the ancestors of the server path and accept either layout so the
45
+ // detection also works for servers installed at other depths.
46
+ let dir = path.dirname(path.resolve(etsLangServerPath));
47
+ for (let depth = 0; depth < 8; depth++) {
48
+ for (const candidate of [
49
+ path.join(dir, 'ohos-typescript', 'lib'),
50
+ path.join(dir, 'node_modules', 'ohos-typescript', 'lib'),
51
+ ]) {
52
+ if (fs.existsSync(path.join(candidate, 'typescript.js'))) return candidate;
53
+ }
54
+ const parent = path.dirname(dir);
55
+ if (parent === dir) break;
56
+ dir = parent;
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ // A usable tsdk must contain lib/typescript.js. The native TypeScript 7 line
62
+ // ships no compiler API there, and settings inherited from another machine may
63
+ // point at a half-installed directory; forwarding such a tsdk makes the server
64
+ // hang inside `initialize` without ever answering.
65
+ function isValidTsdk(dir) {
66
+ return Boolean(dir) && fs.existsSync(path.join(dir, 'typescript.js'));
46
67
  }
47
68
 
48
69
  async function main() {
@@ -109,8 +130,29 @@ async function main() {
109
130
  initializationOptions.tsdk = process.env.ZED_ETS_TSDK || process.env.TSDK || detectTsdk();
110
131
  logger.info(`No tsdk in initializationOptions; falling back to: ${initializationOptions.tsdk}`);
111
132
  }
112
- if (!initializationOptions.ohosSdkPath) {
113
- initializationOptions.ohosSdkPath = process.env.ZED_ETS_OHOS_SDK_PATH || process.env.OHOS_SDK_PATH;
133
+ // A native TypeScript installation may not contain the JS compiler API.
134
+ if (!isValidTsdk(initializationOptions.tsdk)) {
135
+ const fallbackTsdk = detectTsdk();
136
+ if (fallbackTsdk) {
137
+ logger.error(`tsdk ${initializationOptions.tsdk} has no lib/typescript.js; falling back to ${fallbackTsdk}`);
138
+ initializationOptions.tsdk = fallbackTsdk;
139
+ }
140
+ }
141
+
142
+ let hasRealOhosSdk = false;
143
+ try {
144
+ const resolvedSdk = resolveOhosSdkPath({
145
+ configuredPath: initializationOptions.ohosSdkPath,
146
+ env: process.env,
147
+ });
148
+ if (resolvedSdk) {
149
+ initializationOptions.ohosSdkPath = resolvedSdk.path;
150
+ hasRealOhosSdk = true;
151
+ logger.info(`Using HarmonyOS SDK from ${resolvedSdk.source}: ${resolvedSdk.path}`);
152
+ }
153
+ } catch (error) {
154
+ logger.error(error.message);
155
+ initializationOptions.ohosSdkPath = undefined;
114
156
  }
115
157
 
116
158
  // The server cannot finish `initialize` without a tsdk (it fails loading
@@ -123,13 +165,32 @@ async function main() {
123
165
 
124
166
  if (!initializationOptions.ohosSdkPath) {
125
167
  initializationOptions.ohosSdkPath = ensurePlaceholderSdk();
126
- logger.error('No ohosSdkPath in LSP settings or env (ZED_ETS_OHOS_SDK_PATH/OHOS_SDK_PATH); using a placeholder SDK skeleton, ArkUI SDK types will be unavailable until lsp.arkts-language-server.initialization_options.ohosSdkPath is set in Zed settings.');
168
+ logger.error('No valid HarmonyOS SDK was found in LSP settings, environment variables, or standard DevEco Studio locations; using a placeholder SDK skeleton. ArkUI and @kit types will be unavailable until lsp.arkts-language-server.initialization_options.ohosSdkPath is set in Zed settings.');
169
+ }
170
+
171
+ try {
172
+ const resolvedHmsSdk = resolveHmsSdkPath({
173
+ configuredPath: initializationOptions.hmsSdkPath,
174
+ ohosSdkPath: hasRealOhosSdk ? initializationOptions.ohosSdkPath : undefined,
175
+ env: process.env,
176
+ });
177
+ if (resolvedHmsSdk) {
178
+ initializationOptions.hmsSdkPath = resolvedHmsSdk.path;
179
+ logger.info(`Using HMS SDK from ${resolvedHmsSdk.source}: ${resolvedHmsSdk.path}`);
180
+ }
181
+ } catch (error) {
182
+ logger.error(error.message);
183
+ initializationOptions.hmsSdkPath = undefined;
127
184
  }
128
185
 
129
- const ohos = await listHelperPaths(initializationOptions.tsdk, initializationOptions.ohosSdkPath);
186
+ const ohos = await listHelperPaths(
187
+ initializationOptions.tsdk,
188
+ initializationOptions.ohosSdkPath,
189
+ initializationOptions.hmsSdkPath,
190
+ );
130
191
 
131
- // Send both `ohos` and `ets` keys to stay compatible with
132
- // @arkts/language-server v1.2.x (uses `ohos`) and v1.3.x+ (uses `ets`).
192
+ // Current servers read `ets`; retain the `ohos` alias for clients that
193
+ // still inspect the older configuration key.
133
194
  const etsSpecialRequest = {
134
195
  jsonrpc: '2.0',
135
196
  id: `zed-ets-wrapper-${Date.now()}`,
@@ -73,22 +73,30 @@ export async function listLibs(dirPath) {
73
73
  return await getFilesByPattern(dirPath, /d\.ts$/i);
74
74
  }
75
75
 
76
- export async function listHelperPaths(tsDir, harmonyDir) {
76
+ export async function listHelperPaths(tsDir, harmonyDir, hmsDir) {
77
77
  const etsComponentPath = path.join(harmonyDir, '/ets/component');
78
78
  const etsLoaderConfigPath = path.join(harmonyDir, '/ets/build-tools/ets-loader/tsconfig.json');
79
79
  const etsLoaderPath = path.join(harmonyDir, '/ets/build-tools/ets-loader');
80
80
  const etsLoaderLibs = await listLibs(path.join(etsLoaderPath, '/declarations'));
81
81
 
82
+ const modulePaths = ["./api/*", "./kits/*", "./arkts/*"];
83
+ if (hmsDir) {
84
+ modulePaths.push(path.join(hmsDir, 'ets', 'api', '*'));
85
+ modulePaths.push(path.join(hmsDir, 'ets', 'kits', '*'));
86
+ }
87
+
82
88
  return {
83
89
  sdkPath: harmonyDir,
90
+ hmsPath: hmsDir,
91
+ hmsSdkPath: hmsDir,
84
92
  etsComponentPath,
85
93
  etsLoaderConfigPath,
86
94
  etsLoaderPath,
87
95
  baseUrl: path.join(harmonyDir, '/ets'),
88
96
  lib: [...(await listLibs(tsDir)), ...(await listLibs(etsComponentPath)), ...etsLoaderLibs],
89
97
  "paths": {
90
- "*": ["./api/*", "./kits/*", "./arkts/*"],
98
+ "*": modulePaths,
91
99
  "@internal/full/*": ["./api/@internal/full/*"]
92
100
  },
93
101
  };
94
- }
102
+ }
@@ -0,0 +1,182 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const ENVIRONMENT_VARIABLES = [
6
+ 'ZED_ETS_OHOS_SDK_PATH',
7
+ 'OHOS_SDK_PATH',
8
+ 'HARMONYOS_SDK_HOME',
9
+ 'OPENHARMONY_SDK_HOME',
10
+ 'DEVECO_SDK_HOME',
11
+ ];
12
+
13
+ const REQUIRED_SDK_ENTRIES = [
14
+ { relativePath: path.join('ets', 'kits'), type: 'directory' },
15
+ { relativePath: path.join('ets', 'api'), type: 'directory' },
16
+ {
17
+ relativePath: path.join('ets', 'build-tools', 'ets-loader', 'tsconfig.json'),
18
+ type: 'file',
19
+ },
20
+ ];
21
+
22
+ const REQUIRED_HMS_ENTRIES = [
23
+ { relativePath: path.join('ets', 'kits'), type: 'directory' },
24
+ { relativePath: path.join('ets', 'api'), type: 'directory' },
25
+ ];
26
+
27
+ function hasRequiredEntries(candidate, entries) {
28
+ try {
29
+ return entries.every(({ relativePath, type }) => {
30
+ const entryPath = path.join(candidate, relativePath);
31
+ const stats = fs.statSync(entryPath);
32
+ const accessMode = type === 'directory'
33
+ ? fs.constants.R_OK | fs.constants.X_OK
34
+ : fs.constants.R_OK;
35
+ fs.accessSync(entryPath, accessMode);
36
+ return type === 'directory' ? stats.isDirectory() : stats.isFile();
37
+ });
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function isValidOhosSdkPath(candidate) {
44
+ return hasRequiredEntries(candidate, REQUIRED_SDK_ENTRIES);
45
+ }
46
+
47
+ function isValidHmsSdkPath(candidate) {
48
+ return hasRequiredEntries(candidate, REQUIRED_HMS_ENTRIES);
49
+ }
50
+
51
+ function expandHome(candidate, homeDirectory) {
52
+ return candidate.startsWith('~/')
53
+ ? path.join(homeDirectory, candidate.slice(2))
54
+ : candidate;
55
+ }
56
+
57
+ function normalizeCandidate(candidate, homeDirectory = os.homedir()) {
58
+ if (!candidate) return undefined;
59
+
60
+ const expanded = expandHome(candidate, homeDirectory);
61
+ const normalized = path.resolve(expanded);
62
+ const possibleRoots = [
63
+ normalized,
64
+ path.join(normalized, 'openharmony'),
65
+ path.join(normalized, 'default', 'openharmony'),
66
+ ];
67
+
68
+ try {
69
+ const versionDirectories = fs
70
+ .readdirSync(normalized, { withFileTypes: true })
71
+ .filter((entry) => entry.isDirectory() && entry.name !== 'default')
72
+ .map((entry) => entry.name)
73
+ .sort((left, right) => right.localeCompare(left, undefined, { numeric: true }));
74
+ for (const versionDirectory of versionDirectories) {
75
+ possibleRoots.push(path.join(normalized, versionDirectory, 'openharmony'));
76
+ possibleRoots.push(path.join(normalized, versionDirectory));
77
+ }
78
+ } catch {
79
+ // Missing or unreadable candidates are ignored during discovery.
80
+ }
81
+
82
+ return possibleRoots.find(isValidOhosSdkPath);
83
+ }
84
+
85
+ function defaultCandidates(platform, homeDirectory, env) {
86
+ const candidates = [];
87
+ const studioHome = env.DEVECO_STUDIO_HOME || env.DEVECO_HOME;
88
+ if (studioHome) {
89
+ candidates.push(path.join(studioHome, 'sdk'));
90
+ candidates.push(path.join(studioHome, 'Contents', 'sdk'));
91
+ }
92
+
93
+ if (platform === 'darwin') {
94
+ candidates.push(path.join(homeDirectory, 'Library', 'OpenHarmony', 'Sdk'));
95
+ candidates.push(path.join(homeDirectory, 'Library', 'Huawei', 'Sdk'));
96
+ candidates.push('/Applications/DevEco-Studio.app/Contents/sdk');
97
+ candidates.push(path.join(homeDirectory, 'Applications', 'DevEco-Studio.app', 'Contents', 'sdk'));
98
+ } else if (platform === 'win32') {
99
+ if (env.ProgramFiles) {
100
+ candidates.push(path.join(env.ProgramFiles, 'Huawei', 'DevEco Studio', 'sdk'));
101
+ }
102
+ if (env.LOCALAPPDATA) {
103
+ candidates.push(path.join(env.LOCALAPPDATA, 'Huawei', 'DevEcoStudio', 'sdk'));
104
+ }
105
+ } else {
106
+ candidates.push('/opt/DevEco-Studio/sdk');
107
+ candidates.push(path.join(homeDirectory, 'DevEco-Studio', 'sdk'));
108
+ }
109
+
110
+ return candidates;
111
+ }
112
+
113
+ export function resolveOhosSdkPath({
114
+ configuredPath,
115
+ env = process.env,
116
+ platform = process.platform,
117
+ homeDirectory = os.homedir(),
118
+ candidates,
119
+ } = {}) {
120
+ if (configuredPath) {
121
+ const resolved = normalizeCandidate(configuredPath, homeDirectory);
122
+ if (!resolved) {
123
+ throw new Error(
124
+ `Invalid HarmonyOS SDK path: ${configuredPath}. Expected ets/kits, ets/api, and ets/build-tools/ets-loader/tsconfig.json.`,
125
+ );
126
+ }
127
+ return { path: resolved, source: 'settings' };
128
+ }
129
+
130
+ for (const variableName of ENVIRONMENT_VARIABLES) {
131
+ const resolved = normalizeCandidate(env[variableName], homeDirectory);
132
+ if (resolved) {
133
+ return { path: resolved, source: variableName };
134
+ }
135
+ }
136
+
137
+ const searchCandidates = candidates ?? defaultCandidates(platform, homeDirectory, env);
138
+ for (const candidate of searchCandidates) {
139
+ const resolved = normalizeCandidate(candidate, homeDirectory);
140
+ if (resolved) {
141
+ return { path: resolved, source: 'auto-detected' };
142
+ }
143
+ }
144
+
145
+ return undefined;
146
+ }
147
+
148
+ export function resolveHmsSdkPath({
149
+ configuredPath,
150
+ ohosSdkPath,
151
+ env = process.env,
152
+ homeDirectory = os.homedir(),
153
+ } = {}) {
154
+ const candidates = configuredPath
155
+ ? [configuredPath, path.join(configuredPath, 'hms'), path.join(configuredPath, 'default', 'hms')]
156
+ : [
157
+ env.ZED_ETS_HMS_SDK_PATH,
158
+ env.HMS_SDK_PATH,
159
+ ohosSdkPath && path.join(path.dirname(ohosSdkPath), 'hms'),
160
+ ];
161
+
162
+ for (const candidate of candidates) {
163
+ if (!candidate) continue;
164
+ const normalized = path.resolve(expandHome(candidate, homeDirectory));
165
+ if (isValidHmsSdkPath(normalized)) {
166
+ return {
167
+ path: normalized,
168
+ source: configuredPath ? 'settings' : 'auto-detected',
169
+ };
170
+ }
171
+ }
172
+
173
+ if (configuredPath) {
174
+ throw new Error(
175
+ `Invalid HMS SDK path: ${configuredPath}. Expected ets/kits and ets/api.`,
176
+ );
177
+ }
178
+
179
+ return undefined;
180
+ }
181
+
182
+ export { isValidHmsSdkPath, isValidOhosSdkPath };
@@ -0,0 +1,252 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { listHelperPaths } from './lib-expander.js';
6
+ import { resolveHmsSdkPath, resolveOhosSdkPath } from './sdk-discovery.js';
7
+
8
+ const temporaryDirectories = [];
9
+
10
+ async function createSdkRoot(directory, relativePath) {
11
+ const sdkRoot = path.join(directory, relativePath);
12
+ await fs.mkdir(path.join(sdkRoot, 'ets', 'api'), { recursive: true });
13
+ await fs.mkdir(path.join(sdkRoot, 'ets', 'kits'), { recursive: true });
14
+ await fs.mkdir(path.join(sdkRoot, 'ets', 'component'), { recursive: true });
15
+ await fs.mkdir(path.join(sdkRoot, 'ets', 'build-tools', 'ets-loader', 'declarations'), {
16
+ recursive: true,
17
+ });
18
+ await fs.writeFile(
19
+ path.join(sdkRoot, 'ets', 'build-tools', 'ets-loader', 'tsconfig.json'),
20
+ '{}\n',
21
+ );
22
+ await fs.writeFile(
23
+ path.join(sdkRoot, 'ets', 'kits', '@kit.AbilityKit.d.ts'),
24
+ 'export declare class UIAbility {}\n',
25
+ );
26
+
27
+ return sdkRoot;
28
+ }
29
+
30
+ async function createSdkFixture() {
31
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'zed-arkts-sdk-'));
32
+ temporaryDirectories.push(directory);
33
+ const sdkRoot = await createSdkRoot(directory, path.join('sdk', 'default', 'openharmony'));
34
+ return { directory, sdkRoot };
35
+ }
36
+
37
+ async function createHmsSdkRoot(ohosSdkRoot) {
38
+ const hmsSdkRoot = path.join(path.dirname(ohosSdkRoot), 'hms');
39
+ await fs.mkdir(path.join(hmsSdkRoot, 'ets', 'api'), { recursive: true });
40
+ await fs.mkdir(path.join(hmsSdkRoot, 'ets', 'kits'), { recursive: true });
41
+ await fs.writeFile(
42
+ path.join(hmsSdkRoot, 'ets', 'kits', '@kit.TestHmsKit.d.ts'),
43
+ 'export declare const hmsApi: string;\n',
44
+ );
45
+ return hmsSdkRoot;
46
+ }
47
+
48
+ afterEach(async () => {
49
+ await Promise.all(
50
+ temporaryDirectories.splice(0).map((directory) =>
51
+ fs.rm(directory, { recursive: true, force: true }),
52
+ ),
53
+ );
54
+ });
55
+
56
+ describe('HarmonyOS SDK discovery', () => {
57
+ it('normalizes a DevEco SDK directory to its OpenHarmony root', async () => {
58
+ const { directory, sdkRoot } = await createSdkFixture();
59
+
60
+ const result = resolveOhosSdkPath({
61
+ configuredPath: path.join(directory, 'sdk', 'default'),
62
+ env: {},
63
+ candidates: [],
64
+ });
65
+
66
+ expect(result).toEqual({ path: sdkRoot, source: 'settings' });
67
+ });
68
+
69
+ it('prefers the explicitly configured SDK over environment values', async () => {
70
+ const configured = await createSdkFixture();
71
+ const fromEnvironment = await createSdkFixture();
72
+
73
+ const result = resolveOhosSdkPath({
74
+ configuredPath: configured.sdkRoot,
75
+ env: { ZED_ETS_OHOS_SDK_PATH: fromEnvironment.sdkRoot },
76
+ candidates: [],
77
+ });
78
+
79
+ expect(result).toEqual({ path: configured.sdkRoot, source: 'settings' });
80
+ });
81
+
82
+ it('expands a configured OpenHarmony SDK path relative to the supplied home', async () => {
83
+ const homeDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'zed-arkts-tilde-ohos-'));
84
+ temporaryDirectories.push(homeDirectory);
85
+ const sdkRoot = await createSdkRoot(
86
+ homeDirectory,
87
+ path.join('HarmonySdk', 'default', 'openharmony'),
88
+ );
89
+
90
+ const result = resolveOhosSdkPath({
91
+ configuredPath: '~/HarmonySdk',
92
+ env: {},
93
+ candidates: [],
94
+ homeDirectory,
95
+ });
96
+
97
+ expect(result).toEqual({ path: sdkRoot, source: 'settings' });
98
+ });
99
+
100
+ it('discovers the SDK bundled with DevEco Studio', async () => {
101
+ const { directory, sdkRoot } = await createSdkFixture();
102
+
103
+ const result = resolveOhosSdkPath({
104
+ env: {},
105
+ candidates: [path.join(directory, 'sdk')],
106
+ });
107
+
108
+ expect(result).toEqual({ path: sdkRoot, source: 'auto-detected' });
109
+ });
110
+
111
+ it.each([
112
+ ['OpenHarmony', path.join('Library', 'OpenHarmony', 'Sdk')],
113
+ ['Huawei', path.join('Library', 'Huawei', 'Sdk')],
114
+ ])('discovers the macOS %s user SDK directory', async (_vendor, sdkDirectory) => {
115
+ const homeDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'zed-arkts-home-sdk-'));
116
+ temporaryDirectories.push(homeDirectory);
117
+ const sdkRoot = await createSdkRoot(
118
+ homeDirectory,
119
+ path.join(sdkDirectory, 'default', 'openharmony'),
120
+ );
121
+
122
+ const result = resolveOhosSdkPath({
123
+ env: {},
124
+ platform: 'darwin',
125
+ homeDirectory,
126
+ });
127
+
128
+ expect(result).toEqual({ path: sdkRoot, source: 'auto-detected' });
129
+ });
130
+
131
+ it('selects the newest installed version when no default SDK exists', async () => {
132
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'zed-arkts-versioned-sdk-'));
133
+ temporaryDirectories.push(directory);
134
+ await createSdkRoot(directory, path.join('sdk', '12', 'openharmony'));
135
+ const newestSdk = await createSdkRoot(directory, path.join('sdk', '24', 'openharmony'));
136
+
137
+ const result = resolveOhosSdkPath({
138
+ env: {},
139
+ candidates: [path.join(directory, 'sdk')],
140
+ });
141
+
142
+ expect(result).toEqual({ path: newestSdk, source: 'auto-detected' });
143
+ });
144
+
145
+ it('rejects a path that does not contain ArkTS SDK declarations', async () => {
146
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'zed-arkts-invalid-sdk-'));
147
+ temporaryDirectories.push(directory);
148
+
149
+ expect(() =>
150
+ resolveOhosSdkPath({
151
+ configuredPath: directory,
152
+ env: {},
153
+ candidates: [],
154
+ }),
155
+ ).toThrow(/ets\/kits.*ets-loader\/tsconfig\.json/);
156
+ });
157
+
158
+ it('rejects SDK entries with the wrong file types', async () => {
159
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'zed-arkts-malformed-sdk-'));
160
+ temporaryDirectories.push(directory);
161
+ const sdkRoot = path.join(directory, 'openharmony');
162
+ await fs.mkdir(path.join(sdkRoot, 'ets', 'api'), { recursive: true });
163
+ await fs.writeFile(path.join(sdkRoot, 'ets', 'kits'), 'not a directory\n');
164
+ await fs.mkdir(path.join(sdkRoot, 'ets', 'build-tools', 'ets-loader', 'tsconfig.json'), {
165
+ recursive: true,
166
+ });
167
+
168
+ expect(() =>
169
+ resolveOhosSdkPath({ configuredPath: sdkRoot, env: {}, candidates: [] }),
170
+ ).toThrow(/Invalid HarmonyOS SDK path/);
171
+ });
172
+
173
+ it.runIf(process.platform !== 'win32')('rejects SDK directories that cannot be traversed', async () => {
174
+ const { sdkRoot } = await createSdkFixture();
175
+ const apiDirectory = path.join(sdkRoot, 'ets', 'api');
176
+ await fs.chmod(apiDirectory, 0o400);
177
+
178
+ expect(() =>
179
+ resolveOhosSdkPath({ configuredPath: sdkRoot, env: {}, candidates: [] }),
180
+ ).toThrow(/Invalid HarmonyOS SDK path/);
181
+ });
182
+
183
+ it('discovers a sibling HMS SDK and adds it to module resolution paths', async () => {
184
+ const { directory, sdkRoot } = await createSdkFixture();
185
+ const hmsSdkRoot = await createHmsSdkRoot(sdkRoot);
186
+ const tsdk = path.join(directory, 'typescript', 'lib');
187
+ await fs.mkdir(tsdk, { recursive: true });
188
+ await fs.writeFile(path.join(tsdk, 'lib.es5.d.ts'), 'interface Object {}\n');
189
+
190
+ const hmsResult = resolveHmsSdkPath({ ohosSdkPath: sdkRoot, env: {} });
191
+ const helperPaths = await listHelperPaths(tsdk, sdkRoot, hmsResult.path);
192
+
193
+ expect(hmsResult).toEqual({ path: hmsSdkRoot, source: 'auto-detected' });
194
+ expect(helperPaths.hmsSdkPath).toBe(hmsSdkRoot);
195
+ expect(helperPaths.paths['*']).toContain(path.join(hmsSdkRoot, 'ets', 'kits', '*'));
196
+ expect(helperPaths.paths['*']).toContain(path.join(hmsSdkRoot, 'ets', 'api', '*'));
197
+ });
198
+
199
+ it('expands a configured HMS SDK path relative to the supplied home', async () => {
200
+ const homeDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'zed-arkts-tilde-hms-'));
201
+ temporaryDirectories.push(homeDirectory);
202
+ const hmsSdkRoot = path.join(homeDirectory, 'HarmonySdk', 'default', 'hms');
203
+ await fs.mkdir(path.join(hmsSdkRoot, 'ets', 'api'), { recursive: true });
204
+ await fs.mkdir(path.join(hmsSdkRoot, 'ets', 'kits'), { recursive: true });
205
+
206
+ const result = resolveHmsSdkPath({
207
+ configuredPath: '~/HarmonySdk/default/hms',
208
+ env: {},
209
+ homeDirectory,
210
+ });
211
+
212
+ expect(result).toEqual({ path: hmsSdkRoot, source: 'settings' });
213
+ });
214
+
215
+ it('resolves OpenHarmony and HMS kit modules through the generated paths', async () => {
216
+ const typescriptModule = await import('ohos-typescript');
217
+ const ts = typescriptModule.default ?? typescriptModule;
218
+ const { directory, sdkRoot } = await createSdkFixture();
219
+ const hmsSdkRoot = await createHmsSdkRoot(sdkRoot);
220
+ const helperPaths = await listHelperPaths(
221
+ path.join(process.cwd(), 'node_modules', 'ohos-typescript', 'lib'),
222
+ sdkRoot,
223
+ hmsSdkRoot,
224
+ );
225
+ const compilerOptions = {
226
+ moduleResolution: ts.ModuleResolutionKind.NodeJs,
227
+ baseUrl: helperPaths.baseUrl,
228
+ paths: helperPaths.paths,
229
+ };
230
+ const containingFile = path.join(directory, 'entry', 'src', 'main', 'ets', 'Probe.ets');
231
+
232
+ const abilityKit = ts.resolveModuleName(
233
+ '@kit.AbilityKit',
234
+ containingFile,
235
+ compilerOptions,
236
+ ts.sys,
237
+ ).resolvedModule;
238
+ const hmsKit = ts.resolveModuleName(
239
+ '@kit.TestHmsKit',
240
+ containingFile,
241
+ compilerOptions,
242
+ ts.sys,
243
+ ).resolvedModule;
244
+
245
+ expect(abilityKit?.resolvedFileName).toBe(
246
+ path.join(sdkRoot, 'ets', 'kits', '@kit.AbilityKit.d.ts'),
247
+ );
248
+ expect(hmsKit?.resolvedFileName).toBe(
249
+ path.join(hmsSdkRoot, 'ets', 'kits', '@kit.TestHmsKit.d.ts'),
250
+ );
251
+ });
252
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zed-ets-language-server",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "ETS language server wrapper for Zed ArkTS extension.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -17,9 +17,9 @@
17
17
  "author": "liuyanghejerry <liuyanghejerry@126.com>",
18
18
  "license": "MIT",
19
19
  "dependencies": {
20
- "@arkts/language-server": "^1.2.8"
20
+ "@arkts/language-server": "^1.3.10"
21
21
  },
22
22
  "devDependencies": {
23
- "vitest": "^4.0.9"
23
+ "vitest": "^4.1.11"
24
24
  }
25
25
  }
@@ -0,0 +1,232 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { spawn } from 'node:child_process';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+
8
+ const testDirectory = path.dirname(fileURLToPath(import.meta.url));
9
+ let fixtureDirectory;
10
+ let projectDirectory;
11
+ let sourcePath;
12
+ let serverProcess;
13
+ let languageServerVersion;
14
+ let stdoutBuffer = Buffer.alloc(0);
15
+ const messages = [];
16
+ const waiters = [];
17
+
18
+ function send(message) {
19
+ const body = Buffer.from(JSON.stringify(message));
20
+ serverProcess.stdin.write(
21
+ Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body]),
22
+ );
23
+ }
24
+
25
+ function dispatch(message) {
26
+ messages.push(message);
27
+
28
+ if (message.method && message.id !== undefined) {
29
+ let result = null;
30
+ if (message.method === 'workspace/configuration') {
31
+ result = (message.params?.items ?? []).map(() => null);
32
+ } else if (message.method === 'workspace/workspaceFolders') {
33
+ result = [{ uri: pathToFileURL(projectDirectory).href, name: 'project' }];
34
+ }
35
+ send({ jsonrpc: '2.0', id: message.id, result });
36
+ }
37
+
38
+ for (let index = waiters.length - 1; index >= 0; index -= 1) {
39
+ if (!waiters[index].predicate(message)) continue;
40
+ const [waiter] = waiters.splice(index, 1);
41
+ clearTimeout(waiter.timer);
42
+ waiter.resolve(message);
43
+ }
44
+ }
45
+
46
+ function collectMessages(chunk) {
47
+ stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]);
48
+ while (true) {
49
+ const headerEnd = stdoutBuffer.indexOf('\r\n\r\n');
50
+ if (headerEnd === -1) return;
51
+ const header = stdoutBuffer.subarray(0, headerEnd).toString('ascii');
52
+ const lengthMatch = header.match(/Content-Length:\s*(\d+)/i);
53
+ if (!lengthMatch) throw new Error('LSP response is missing Content-Length');
54
+ const bodyLength = Number(lengthMatch[1]);
55
+ const bodyStart = headerEnd + 4;
56
+ if (stdoutBuffer.length < bodyStart + bodyLength) return;
57
+ const body = stdoutBuffer.subarray(bodyStart, bodyStart + bodyLength);
58
+ stdoutBuffer = stdoutBuffer.subarray(bodyStart + bodyLength);
59
+ dispatch(JSON.parse(body.toString('utf8')));
60
+ }
61
+ }
62
+
63
+ function waitForMessage(predicate, timeout = 30000) {
64
+ const existing = messages.find(predicate);
65
+ if (existing) return Promise.resolve(existing);
66
+ return new Promise((resolve, reject) => {
67
+ const timer = setTimeout(() => reject(new Error('Timed out waiting for LSP message')), timeout);
68
+ waiters.push({ predicate, resolve, timer });
69
+ });
70
+ }
71
+
72
+ beforeAll(async () => {
73
+ fixtureDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'zed-arkts-real-lsp-'));
74
+ projectDirectory = path.join(fixtureDirectory, 'project');
75
+ const sdkDirectory = path.join(fixtureDirectory, 'studio', 'Contents', 'sdk', 'default');
76
+ const ohosSdk = path.join(sdkDirectory, 'openharmony');
77
+ const hmsSdk = path.join(sdkDirectory, 'hms');
78
+ sourcePath = path.join(projectDirectory, 'entry', 'src', 'main', 'ets', 'Probe.ets');
79
+
80
+ await Promise.all([
81
+ fs.mkdir(path.dirname(sourcePath), { recursive: true }),
82
+ fs.mkdir(path.join(ohosSdk, 'ets', 'api'), { recursive: true }),
83
+ fs.mkdir(path.join(ohosSdk, 'ets', 'kits'), { recursive: true }),
84
+ fs.mkdir(path.join(ohosSdk, 'ets', 'component'), { recursive: true }),
85
+ fs.mkdir(path.join(ohosSdk, 'ets', 'build-tools', 'ets-loader', 'declarations'), {
86
+ recursive: true,
87
+ }),
88
+ fs.mkdir(path.join(hmsSdk, 'ets', 'api'), { recursive: true }),
89
+ fs.mkdir(path.join(hmsSdk, 'ets', 'kits'), { recursive: true }),
90
+ ]);
91
+ await Promise.all([
92
+ fs.writeFile(path.join(ohosSdk, 'ets', 'build-tools', 'ets-loader', 'tsconfig.json'), '{}\n'),
93
+ fs.writeFile(
94
+ path.join(ohosSdk, 'ets', 'kits', '@kit.AbilityKit.d.ts'),
95
+ 'export declare class UIAbility {}\n',
96
+ ),
97
+ fs.writeFile(
98
+ path.join(hmsSdk, 'ets', 'kits', '@kit.TestHmsKit.d.ts'),
99
+ 'export declare const hmsApi: string;\n',
100
+ ),
101
+ fs.writeFile(
102
+ path.join(projectDirectory, 'build-profile.json5'),
103
+ '{ app: { products: [{ name: "default" }] }, modules: [{ name: "entry", srcPath: "./entry" }] }\n',
104
+ ),
105
+ fs.writeFile(
106
+ path.join(projectDirectory, 'oh-package.json5'),
107
+ '{ name: "probe", version: "1.0.0" }\n',
108
+ ),
109
+ fs.writeFile(
110
+ path.join(projectDirectory, 'entry', 'build-profile.json5'),
111
+ '{ apiType: "stageMode", buildOption: {} }\n',
112
+ ),
113
+ fs.writeFile(
114
+ sourcePath,
115
+ "import { UIAbility } from '@kit.AbilityKit';\n" +
116
+ "import { hmsApi } from '@kit.TestHmsKit';\n" +
117
+ "import { missingApi } from '@kit.DoesNotExist';\n" +
118
+ 'export const probe: UIAbility | string = hmsApi;\n' +
119
+ 'export const missingProbe = missingApi;\n',
120
+ ),
121
+ ]);
122
+
123
+ const wrapperPath = path.join(testDirectory, '..', '..', 'index.js');
124
+ const languageServerPath = path.join(
125
+ testDirectory,
126
+ '..',
127
+ '..',
128
+ 'node_modules',
129
+ '@arkts',
130
+ 'language-server',
131
+ 'bin',
132
+ 'ets-language-server.js',
133
+ );
134
+ const languageServerPackage = JSON.parse(
135
+ await fs.readFile(
136
+ path.join(testDirectory, '..', '..', 'node_modules', '@arkts', 'language-server', 'package.json'),
137
+ 'utf8',
138
+ ),
139
+ );
140
+ languageServerVersion = languageServerPackage.version;
141
+ const env = { ...process.env };
142
+ for (const variableName of [
143
+ 'ZED_ETS_OHOS_SDK_PATH',
144
+ 'OHOS_SDK_PATH',
145
+ 'HARMONYOS_SDK_HOME',
146
+ 'OPENHARMONY_SDK_HOME',
147
+ 'DEVECO_SDK_HOME',
148
+ 'ZED_ETS_HMS_SDK_PATH',
149
+ 'HMS_SDK_PATH',
150
+ 'ZED_ETS_TSDK',
151
+ 'TSDK',
152
+ ]) {
153
+ delete env[variableName];
154
+ }
155
+ Object.assign(env, {
156
+ ETS_LANG_SERVER: languageServerPath,
157
+ DEVECO_STUDIO_HOME: path.join(fixtureDirectory, 'studio'),
158
+ });
159
+ serverProcess = spawn(process.execPath, [wrapperPath], {
160
+ cwd: projectDirectory,
161
+ env,
162
+ stdio: ['pipe', 'pipe', 'pipe'],
163
+ });
164
+ serverProcess.stdout.on('data', collectMessages);
165
+ });
166
+
167
+ afterAll(async () => {
168
+ if (serverProcess && serverProcess.exitCode === null) {
169
+ await new Promise((resolve) => {
170
+ const timeout = setTimeout(() => serverProcess.kill('SIGKILL'), 5000);
171
+ serverProcess.once('exit', () => {
172
+ clearTimeout(timeout);
173
+ resolve();
174
+ });
175
+ serverProcess.kill();
176
+ });
177
+ }
178
+ await fs.rm(fixtureDirectory, { recursive: true, force: true });
179
+ });
180
+
181
+ describe('real ArkTS language server Kit diagnostics', () => {
182
+ it('resolves OpenHarmony and HMS Kit modules with language server 1.3.x', async () => {
183
+ expect(languageServerVersion).toMatch(/^1\.3\./);
184
+ const rootUri = pathToFileURL(projectDirectory).href;
185
+ send({
186
+ jsonrpc: '2.0',
187
+ id: 1,
188
+ method: 'initialize',
189
+ params: {
190
+ processId: process.pid,
191
+ rootUri,
192
+ workspaceFolders: [{ uri: rootUri, name: 'project' }],
193
+ capabilities: {
194
+ workspace: { configuration: true, workspaceFolders: true },
195
+ textDocument: { publishDiagnostics: {} },
196
+ },
197
+ initializationOptions: {},
198
+ },
199
+ });
200
+ const initializeResponse = await waitForMessage((message) => message.id === 1);
201
+ expect(initializeResponse.error).toBeUndefined();
202
+ send({ jsonrpc: '2.0', method: 'initialized', params: {} });
203
+
204
+ const uri = pathToFileURL(sourcePath).href;
205
+ send({
206
+ jsonrpc: '2.0',
207
+ method: 'textDocument/didOpen',
208
+ params: {
209
+ textDocument: {
210
+ uri,
211
+ languageId: 'ets',
212
+ version: 1,
213
+ text: await fs.readFile(sourcePath, 'utf8'),
214
+ },
215
+ },
216
+ });
217
+ const diagnostics = await waitForMessage(
218
+ (message) =>
219
+ message.method === 'textDocument/publishDiagnostics' &&
220
+ message.params.uri === uri &&
221
+ message.params.diagnostics.some((diagnostic) => Number(diagnostic.code) === 2307),
222
+ );
223
+
224
+ const unresolvedModules = diagnostics.params.diagnostics.filter(
225
+ (diagnostic) => Number(diagnostic.code) === 2307,
226
+ );
227
+ expect(unresolvedModules).toHaveLength(1);
228
+ expect(unresolvedModules[0].message).toContain('@kit.DoesNotExist');
229
+ expect(unresolvedModules[0].message).not.toContain('@kit.AbilityKit');
230
+ expect(unresolvedModules[0].message).not.toContain('@kit.TestHmsKit');
231
+ }, 40000);
232
+ });
@@ -1,18 +1,36 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
2
  import { spawn } from 'child_process';
3
+ import { mkdtempSync } from 'fs';
4
+ import { tmpdir } from 'os';
3
5
  import { fileURLToPath } from 'url';
4
6
  import { dirname, join } from 'path';
5
7
 
6
8
  const __filename = fileURLToPath(import.meta.url);
7
9
  const __dirname = dirname(__filename);
8
10
 
11
+ const serverPath = join(__dirname, '../../index.js');
12
+ const mockServerPath = join(__dirname, '../mocks/mock-ets-server.js');
13
+ const bundledTsdk = join(__dirname, '../../node_modules/ohos-typescript/lib');
14
+
15
+ // Hermetic env: never inherit ambient tsdk/sdk settings from the shell.
16
+ function baseEnv(overrides = {}) {
17
+ return {
18
+ ...process.env,
19
+ ETS_LANG_SERVER: mockServerPath,
20
+ TSDK: '',
21
+ ZED_ETS_TSDK: '',
22
+ OHOS_SDK_PATH: '',
23
+ ZED_ETS_OHOS_SDK_PATH: '',
24
+ ...overrides,
25
+ };
26
+ }
27
+
9
28
  /**
10
29
  * 创建 LSP 消息
11
30
  */
12
31
  function createLSPMessage(content) {
13
- const json = JSON.stringify(content);
14
- const contentLength = Buffer.byteLength(json, 'utf8');
15
- return `Content-Length: ${contentLength}\r\n\r\n${json}`;
32
+ const contentLength = Buffer.byteLength(JSON.stringify(content));
33
+ return `Content-Length: ${contentLength}\r\n\r\n${JSON.stringify(content)}`;
16
34
  }
17
35
 
18
36
  /**
@@ -22,7 +40,7 @@ function parseLSPResponse(data) {
22
40
  const text = data.toString();
23
41
  const match = text.match(/Content-Length: (\d+)\r\n\r\n(.*)/s);
24
42
  if (!match) return null;
25
-
43
+
26
44
  try {
27
45
  return JSON.parse(match[2]);
28
46
  } catch (e) {
@@ -30,28 +48,77 @@ function parseLSPResponse(data) {
30
48
  }
31
49
  }
32
50
 
51
+ /**
52
+ * 等待特定响应
53
+ */
54
+ function waitForResponse(responses, predicate, timeout = 2000) {
55
+ return new Promise((resolve, reject) => {
56
+ const startTime = Date.now();
57
+ const checkInterval = setInterval(() => {
58
+ const response = responses.find(predicate);
59
+ if (response) {
60
+ clearInterval(checkInterval);
61
+ resolve(response);
62
+ } else if (Date.now() - startTime > timeout) {
63
+ clearInterval(checkInterval);
64
+ reject(new Error('Timeout waiting for response'));
65
+ }
66
+ }, 50);
67
+ });
68
+ }
69
+
70
+ function startWrapper(env) {
71
+ const serverProcess = spawn('node', [serverPath], {
72
+ stdio: ['pipe', 'pipe', 'pipe'],
73
+ env,
74
+ });
75
+
76
+ const responses = [];
77
+ let stdoutBuffer = Buffer.alloc(0);
78
+ serverProcess.stdout.on('data', (data) => {
79
+ stdoutBuffer = Buffer.concat([stdoutBuffer, data]);
80
+ // SDK discovery can make initialize responses span multiple pipe chunks.
81
+ while (true) {
82
+ const headerEnd = stdoutBuffer.indexOf('\r\n\r\n');
83
+ if (headerEnd < 0) return;
84
+ const header = stdoutBuffer.subarray(0, headerEnd).toString('ascii');
85
+ const lengthMatch = header.match(/Content-Length:\s*(\d+)/i);
86
+ if (!lengthMatch) throw new Error('Invalid LSP response header');
87
+ const bodyStart = headerEnd + 4;
88
+ const bodyEnd = bodyStart + Number(lengthMatch[1]);
89
+ if (stdoutBuffer.length < bodyEnd) return;
90
+ responses.push(JSON.parse(stdoutBuffer.subarray(bodyStart, bodyEnd).toString('utf8')));
91
+ stdoutBuffer = stdoutBuffer.subarray(bodyEnd);
92
+ }
93
+ });
94
+
95
+ serverProcess.stderr.on('data', (data) => {
96
+ console.error(`LSP Server Error: ${data}`);
97
+ });
98
+
99
+ return { serverProcess, responses };
100
+ }
101
+
102
+ function initializeMessage(id) {
103
+ return createLSPMessage({
104
+ jsonrpc: '2.0',
105
+ id,
106
+ method: 'initialize',
107
+ params: {
108
+ processId: null,
109
+ rootUri: null,
110
+ capabilities: {},
111
+ },
112
+ });
113
+ }
114
+
33
115
  describe('LSP Server Integration Tests', () => {
34
116
  let serverProcess;
35
117
  let responses = [];
118
+ let messageId = 1;
36
119
 
37
120
  beforeAll(() => {
38
- // 启动 LSP 服务器
39
- const serverPath = join(__dirname, '../../index.js');
40
- serverProcess = spawn('node', [serverPath], {
41
- stdio: ['pipe', 'pipe', 'pipe']
42
- });
43
-
44
- // 收集响应
45
- serverProcess.stdout.on('data', (data) => {
46
- const response = parseLSPResponse(data);
47
- if (response) {
48
- responses.push(response);
49
- }
50
- });
51
-
52
- serverProcess.stderr.on('data', (data) => {
53
- console.error(`LSP Server Error: ${data}`);
54
- });
121
+ ({ serverProcess, responses } = startWrapper(baseEnv()));
55
122
  });
56
123
 
57
124
  afterAll(() => {
@@ -60,65 +127,78 @@ describe('LSP Server Integration Tests', () => {
60
127
  }
61
128
  });
62
129
 
63
- it('should respond to initialize request', (done) => {
64
- const initRequest = {
65
- jsonrpc: '2.0',
66
- id: 1,
67
- method: 'initialize',
68
- params: {
69
- processId: process.pid,
70
- rootUri: null,
71
- capabilities: {}
72
- }
73
- };
130
+ it('should respond to initialize request', async () => {
131
+ serverProcess.stdin.write(initializeMessage(messageId));
132
+ const initResponse = await waitForResponse(responses, (r) => r.id === messageId);
133
+ messageId++;
134
+
135
+ expect(initResponse.result).toBeDefined();
136
+ expect(initResponse.result.capabilities).toBeDefined();
137
+ });
74
138
 
75
- const message = createLSPMessage(initRequest);
76
- serverProcess.stdin.write(message);
77
-
78
- // 等待响应
79
- setTimeout(() => {
80
- const initResponse = responses.find(r => r.id === 1);
81
- expect(initResponse).toBeDefined();
82
- expect(initResponse.result).toBeDefined();
83
- expect(initResponse.result.capabilities).toBeDefined();
84
- done();
85
- }, 1000);
139
+ it('should auto-detect the bundled tsdk when none is configured', async () => {
140
+ serverProcess.stdin.write(initializeMessage(messageId));
141
+ const initResponse = await waitForResponse(responses, (r) => r.id === messageId);
142
+ messageId++;
143
+
144
+ expect(initResponse.result.initializationOptions.typescript.tsdk).toBe(bundledTsdk);
86
145
  });
87
146
 
88
- it('should accept initialized notification', (done) => {
89
- const initializedNotif = {
147
+ it('should accept initialized notification', async () => {
148
+ serverProcess.stdin.write(createLSPMessage({
90
149
  jsonrpc: '2.0',
91
150
  method: 'initialized',
92
- params: {}
93
- };
94
-
95
- const message = createLSPMessage(initializedNotif);
96
- serverProcess.stdin.write(message);
151
+ params: {},
152
+ }));
97
153
 
98
154
  // 通知不需要响应,只需确保不崩溃
99
- setTimeout(() => {
100
- expect(serverProcess.killed).toBe(false);
101
- done();
102
- }, 500);
155
+ await new Promise((resolve) => setTimeout(resolve, 300));
156
+ expect(serverProcess.exitCode).toBeNull();
103
157
  });
104
158
 
105
- it('should handle shutdown request', (done) => {
106
- const shutdownRequest = {
159
+ it('should handle shutdown request', async () => {
160
+ serverProcess.stdin.write(createLSPMessage({
107
161
  jsonrpc: '2.0',
108
162
  id: 99,
109
163
  method: 'shutdown',
110
- params: null
111
- };
164
+ params: null,
165
+ }));
166
+
167
+ const shutdownResponse = await waitForResponse(responses, (r) => r.id === 99);
168
+ // 某些 LSP 服务器可能返回 null result
169
+ expect(shutdownResponse).toBeDefined();
170
+ });
171
+ });
172
+
173
+ describe('LSP tsdk fallback', () => {
174
+ // A tsdk without lib/typescript.js (e.g. the native TypeScript 7 line)
175
+ // used to hang the real server inside initialize. The wrapper must
176
+ // substitute the bundled ohos-typescript instead of forwarding it.
177
+ it('substitutes a broken TSDK env value with the bundled ohos-typescript', async () => {
178
+ const brokenTsdkDir = mkdtempSync(join(tmpdir(), 'zed-ets-broken-tsdk-'));
179
+ const { serverProcess, responses } = startWrapper(baseEnv({ TSDK: brokenTsdkDir }));
180
+
181
+ try {
182
+ serverProcess.stdin.write(initializeMessage(1));
183
+ const initResponse = await waitForResponse(responses, (r) => r.id === 1);
184
+
185
+ expect(initResponse.result.initializationOptions.typescript.tsdk).toBe(bundledTsdk);
186
+ } finally {
187
+ serverProcess.kill();
188
+ }
189
+ });
190
+
191
+ it('forwards a valid TSDK unchanged', async () => {
192
+ const { serverProcess, responses } = startWrapper(baseEnv({ TSDK: bundledTsdk }));
112
193
 
113
- const message = createLSPMessage(shutdownRequest);
114
- serverProcess.stdin.write(message);
194
+ try {
195
+ serverProcess.stdin.write(initializeMessage(1));
196
+ const initResponse = await waitForResponse(responses, (r) => r.id === 1);
115
197
 
116
- setTimeout(() => {
117
- const shutdownResponse = responses.find(r => r.id === 99);
118
- // 某些 LSP 服务器可能返回 null result
119
- expect(shutdownResponse).toBeDefined();
120
- done();
121
- }, 1000);
198
+ expect(initResponse.result.initializationOptions.typescript.tsdk).toBe(bundledTsdk);
199
+ } finally {
200
+ serverProcess.kill();
201
+ }
122
202
  });
123
203
  });
124
204
 
@@ -126,7 +206,7 @@ describe('LSP Message Protocol', () => {
126
206
  it('should format messages correctly', () => {
127
207
  const content = { jsonrpc: '2.0', method: 'test' };
128
208
  const message = createLSPMessage(content);
129
-
209
+
130
210
  expect(message).toContain('Content-Length:');
131
211
  expect(message).toContain('\r\n\r\n');
132
212
  expect(message).toContain(JSON.stringify(content));
@@ -140,7 +220,7 @@ describe('LSP Message Protocol', () => {
140
220
  };
141
221
  const data = createLSPMessage(mockResponse);
142
222
  const parsed = parseLSPResponse(Buffer.from(data));
143
-
223
+
144
224
  expect(parsed).toEqual(mockResponse);
145
225
  });
146
226
  });
@@ -0,0 +1,167 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { spawn } from 'node:child_process';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const testDirectory = path.dirname(fileURLToPath(import.meta.url));
9
+ let fixtureDirectory;
10
+ let sdkRoot;
11
+ let hmsSdkRoot;
12
+ let serverProcess;
13
+ let stdoutBuffer = Buffer.alloc(0);
14
+ const responses = [];
15
+
16
+ function encodeLspMessage(message) {
17
+ const body = Buffer.from(JSON.stringify(message));
18
+ return Buffer.concat([
19
+ Buffer.from(`Content-Length: ${body.length}\r\n\r\n`),
20
+ body,
21
+ ]);
22
+ }
23
+
24
+ function collectLspMessages(chunk) {
25
+ stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]);
26
+
27
+ while (true) {
28
+ const headerEnd = stdoutBuffer.indexOf('\r\n\r\n');
29
+ if (headerEnd === -1) return;
30
+
31
+ const header = stdoutBuffer.subarray(0, headerEnd).toString('ascii');
32
+ const lengthMatch = header.match(/Content-Length:\s*(\d+)/i);
33
+ if (!lengthMatch) return;
34
+
35
+ const bodyStart = headerEnd + 4;
36
+ const bodyLength = Number(lengthMatch[1]);
37
+ if (stdoutBuffer.length < bodyStart + bodyLength) return;
38
+
39
+ const body = stdoutBuffer.subarray(bodyStart, bodyStart + bodyLength);
40
+ responses.push(JSON.parse(body.toString('utf8')));
41
+ stdoutBuffer = stdoutBuffer.subarray(bodyStart + bodyLength);
42
+ }
43
+ }
44
+
45
+ async function waitForResponse(predicate, timeout = 3000) {
46
+ const deadline = Date.now() + timeout;
47
+ while (Date.now() < deadline) {
48
+ const response = responses.find(predicate);
49
+ if (response) return response;
50
+ await new Promise((resolve) => setTimeout(resolve, 25));
51
+ }
52
+ throw new Error('Timed out waiting for LSP response');
53
+ }
54
+
55
+ beforeAll(async () => {
56
+ fixtureDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'zed-arkts-deveco-'));
57
+ sdkRoot = path.join(fixtureDirectory, 'Contents', 'sdk', 'default', 'openharmony');
58
+ await fs.mkdir(path.join(sdkRoot, 'ets', 'api'), { recursive: true });
59
+ await fs.mkdir(path.join(sdkRoot, 'ets', 'kits'), { recursive: true });
60
+ await fs.mkdir(path.join(sdkRoot, 'ets', 'component'), { recursive: true });
61
+ await fs.mkdir(path.join(sdkRoot, 'ets', 'build-tools', 'ets-loader', 'declarations'), {
62
+ recursive: true,
63
+ });
64
+ await fs.writeFile(
65
+ path.join(sdkRoot, 'ets', 'build-tools', 'ets-loader', 'tsconfig.json'),
66
+ '{}\n',
67
+ );
68
+ await fs.writeFile(
69
+ path.join(sdkRoot, 'ets', 'kits', '@kit.AbilityKit.d.ts'),
70
+ 'export declare class UIAbility {}\n',
71
+ );
72
+ hmsSdkRoot = path.join(fixtureDirectory, 'Contents', 'sdk', 'default', 'hms');
73
+ await fs.mkdir(path.join(hmsSdkRoot, 'ets', 'api'), { recursive: true });
74
+ await fs.mkdir(path.join(hmsSdkRoot, 'ets', 'kits'), { recursive: true });
75
+ await fs.writeFile(
76
+ path.join(hmsSdkRoot, 'ets', 'kits', '@kit.TestHmsKit.d.ts'),
77
+ 'export declare const hmsApi: string;\n',
78
+ );
79
+ const placeholderSiblingHms = path.join(fixtureDirectory, 'hms');
80
+ await fs.mkdir(path.join(placeholderSiblingHms, 'ets', 'api'), { recursive: true });
81
+ await fs.mkdir(path.join(placeholderSiblingHms, 'ets', 'kits'), { recursive: true });
82
+
83
+ const wrapperPath = path.join(testDirectory, '..', '..', 'index.js');
84
+ const mockServerPath = path.join(testDirectory, '..', 'mocks', 'mock-ets-server.js');
85
+ const tsdk = path.join(testDirectory, '..', '..', 'node_modules', 'ohos-typescript', 'lib');
86
+ const env = { ...process.env };
87
+ delete env.ZED_ETS_OHOS_SDK_PATH;
88
+ delete env.OHOS_SDK_PATH;
89
+ delete env.HARMONYOS_SDK_HOME;
90
+ delete env.OPENHARMONY_SDK_HOME;
91
+ delete env.DEVECO_SDK_HOME;
92
+ delete env.ZED_ETS_HMS_SDK_PATH;
93
+ delete env.HMS_SDK_PATH;
94
+ Object.assign(env, {
95
+ ETS_LANG_SERVER: mockServerPath,
96
+ ZED_ETS_TSDK: tsdk,
97
+ DEVECO_STUDIO_HOME: fixtureDirectory,
98
+ TMPDIR: fixtureDirectory,
99
+ });
100
+
101
+ serverProcess = spawn(process.execPath, [wrapperPath], {
102
+ env,
103
+ stdio: ['pipe', 'pipe', 'pipe'],
104
+ });
105
+ serverProcess.stdout.on('data', collectLspMessages);
106
+ });
107
+
108
+ afterAll(async () => {
109
+ serverProcess?.kill();
110
+ await fs.rm(fixtureDirectory, { recursive: true, force: true });
111
+ });
112
+
113
+ describe('SDK initialization', () => {
114
+ it('injects the DevEco OpenHarmony SDK into ArkTS initialization', async () => {
115
+ serverProcess.stdin.write(
116
+ encodeLspMessage({
117
+ jsonrpc: '2.0',
118
+ id: 1,
119
+ method: 'initialize',
120
+ params: {
121
+ processId: process.pid,
122
+ rootUri: 'file:///tmp/harmony-project',
123
+ capabilities: {},
124
+ initializationOptions: {},
125
+ },
126
+ }),
127
+ );
128
+
129
+ const response = await waitForResponse((message) => message.id === 1);
130
+ const initializationOptions = response.result.initializationOptions;
131
+
132
+ expect(initializationOptions.ohos.sdkPath).toBe(sdkRoot);
133
+ expect(initializationOptions.ets.sdkPath).toBe(sdkRoot);
134
+ expect(initializationOptions.ohos.baseUrl).toBe(path.join(sdkRoot, 'ets'));
135
+ expect(initializationOptions.ets.hmsPath).toBe(hmsSdkRoot);
136
+ expect(initializationOptions.ohos.hmsSdkPath).toBe(hmsSdkRoot);
137
+ expect(initializationOptions.ohos.paths['*']).toContain(
138
+ path.join(hmsSdkRoot, 'ets', 'kits', '*'),
139
+ );
140
+ });
141
+
142
+ it('does not infer an HMS SDK from the placeholder OpenHarmony SDK', async () => {
143
+ serverProcess.stdin.write(
144
+ encodeLspMessage({
145
+ jsonrpc: '2.0',
146
+ id: 2,
147
+ method: 'initialize',
148
+ params: {
149
+ processId: process.pid,
150
+ rootUri: 'file:///tmp/harmony-project',
151
+ capabilities: {},
152
+ initializationOptions: {
153
+ ohosSdkPath: path.join(fixtureDirectory, 'invalid-openharmony'),
154
+ },
155
+ },
156
+ }),
157
+ );
158
+
159
+ const response = await waitForResponse((message) => message.id === 2);
160
+ const initializationOptions = response.result.initializationOptions;
161
+
162
+ expect(initializationOptions.ets.sdkPath).toBe(
163
+ path.join(fixtureDirectory, 'zed-ets-empty-ohos-sdk'),
164
+ );
165
+ expect(initializationOptions.ets.hmsPath).toBeUndefined();
166
+ });
167
+ });
@@ -91,10 +91,12 @@ struct Test {
91
91
  textDocumentSync: 1,
92
92
  documentFormattingProvider: true,
93
93
  documentRangeFormattingProvider: true
94
- }
94
+ },
95
+ // Echo what the wrapper forwarded so tests can assert on it.
96
+ initializationOptions: message.params?.initializationOptions ?? {}
95
97
  }
96
98
  };
97
-
99
+
98
100
  process.send(response);
99
101
  }
100
102