client.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. import { describe, expect, test } from "bun:test"
  2. import path from "path"
  3. import { pathToFileURL } from "url"
  4. import { tmpdir, withTestInstance } from "../fixture/fixture"
  5. import { LSPClient } from "@/lsp/client"
  6. import * as LSPServer from "@/lsp/server"
  7. function spawnFakeServer() {
  8. const { spawn } = require("child_process")
  9. const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js")
  10. return {
  11. process: spawn(process.execPath, [serverPath], {
  12. stdio: "pipe",
  13. }),
  14. }
  15. }
  16. describe("LSPClient interop", () => {
  17. test("handles workspace/workspaceFolders request", async () => {
  18. const handle = spawnFakeServer() as any
  19. const client = await withTestInstance({
  20. directory: process.cwd(),
  21. fn: (ctx) =>
  22. LSPClient.create({
  23. serverID: "fake",
  24. server: handle as unknown as LSPServer.Handle,
  25. root: process.cwd(),
  26. directory: process.cwd(),
  27. instance: ctx,
  28. }),
  29. })
  30. await client.connection.sendNotification("test/trigger", {
  31. method: "workspace/workspaceFolders",
  32. })
  33. await new Promise((resolve) => setTimeout(resolve, 100))
  34. expect(client.connection).toBeDefined()
  35. await client.shutdown()
  36. })
  37. test("handles client/registerCapability request", async () => {
  38. const handle = spawnFakeServer() as any
  39. const client = await withTestInstance({
  40. directory: process.cwd(),
  41. fn: (ctx) =>
  42. LSPClient.create({
  43. serverID: "fake",
  44. server: handle as unknown as LSPServer.Handle,
  45. root: process.cwd(),
  46. directory: process.cwd(),
  47. instance: ctx,
  48. }),
  49. })
  50. await client.connection.sendNotification("test/trigger", {
  51. method: "client/registerCapability",
  52. })
  53. await new Promise((resolve) => setTimeout(resolve, 100))
  54. expect(client.connection).toBeDefined()
  55. await client.shutdown()
  56. })
  57. test("handles client/unregisterCapability request", async () => {
  58. const handle = spawnFakeServer() as any
  59. const client = await withTestInstance({
  60. directory: process.cwd(),
  61. fn: (ctx) =>
  62. LSPClient.create({
  63. serverID: "fake",
  64. server: handle as unknown as LSPServer.Handle,
  65. root: process.cwd(),
  66. directory: process.cwd(),
  67. instance: ctx,
  68. }),
  69. })
  70. await client.connection.sendNotification("test/trigger", {
  71. method: "client/unregisterCapability",
  72. })
  73. await new Promise((resolve) => setTimeout(resolve, 100))
  74. expect(client.connection).toBeDefined()
  75. await client.shutdown()
  76. })
  77. test("initialize does not overclaim unsupported diagnostics capabilities", async () => {
  78. const handle = spawnFakeServer() as any
  79. const client = await withTestInstance({
  80. directory: process.cwd(),
  81. fn: (ctx) =>
  82. LSPClient.create({
  83. serverID: "fake",
  84. server: handle as unknown as LSPServer.Handle,
  85. root: process.cwd(),
  86. directory: process.cwd(),
  87. instance: ctx,
  88. }),
  89. })
  90. const params = await client.connection.sendRequest<any>("test/get-initialize-params", {})
  91. expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false)
  92. expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false)
  93. await client.shutdown()
  94. })
  95. test("workspace/configuration returns one result per requested item", async () => {
  96. const handle = spawnFakeServer() as any
  97. const initialization = {
  98. alpha: {
  99. beta: 1,
  100. },
  101. gamma: true,
  102. }
  103. const client = await withTestInstance({
  104. directory: process.cwd(),
  105. fn: (ctx) =>
  106. LSPClient.create({
  107. serverID: "fake",
  108. server: {
  109. ...(handle as unknown as LSPServer.Handle),
  110. initialization,
  111. },
  112. root: process.cwd(),
  113. directory: process.cwd(),
  114. instance: ctx,
  115. }),
  116. })
  117. const response = await client.connection.sendRequest<any[]>("test/request-configuration", {
  118. items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}],
  119. })
  120. expect(response).toEqual([{ beta: 1 }, 1, null, initialization])
  121. await client.shutdown()
  122. })
  123. test("sends ranged didChange for incremental sync servers", async () => {
  124. const handle = spawnFakeServer() as any
  125. await using tmp = await tmpdir()
  126. const file = path.join(tmp.path, "client.ts")
  127. await Bun.write(file, "first\n")
  128. await withTestInstance({
  129. directory: tmp.path,
  130. fn: async (ctx) => {
  131. const client = await LSPClient.create({
  132. serverID: "fake",
  133. server: handle as unknown as LSPServer.Handle,
  134. root: tmp.path,
  135. directory: tmp.path,
  136. instance: ctx,
  137. })
  138. await client.notify.open({ path: file })
  139. await Bun.write(file, "second\nthird\n")
  140. await client.notify.open({ path: file })
  141. const change = await client.connection.sendRequest<{
  142. textDocument: { version: number }
  143. contentChanges: {
  144. range?: { start: { line: number; character: number }; end: { line: number; character: number } }
  145. text: string
  146. }[]
  147. }>("test/get-last-change", {})
  148. expect(change.textDocument.version).toBe(1)
  149. expect(change.contentChanges).toEqual([
  150. {
  151. range: {
  152. start: { line: 0, character: 0 },
  153. end: { line: 1, character: 0 },
  154. },
  155. text: "second\nthird\n",
  156. },
  157. ])
  158. await client.shutdown()
  159. },
  160. })
  161. })
  162. test("document mode falls back to push diagnostics", async () => {
  163. const handle = spawnFakeServer() as any
  164. await using tmp = await tmpdir()
  165. const file = path.join(tmp.path, "client.ts")
  166. await Bun.write(file, "const x = 1\n")
  167. await withTestInstance({
  168. directory: tmp.path,
  169. fn: async (ctx) => {
  170. const client = await LSPClient.create({
  171. serverID: "fake",
  172. server: handle as unknown as LSPServer.Handle,
  173. root: tmp.path,
  174. directory: tmp.path,
  175. instance: ctx,
  176. })
  177. const version = await client.notify.open({ path: file })
  178. const wait = client.waitForDiagnostics({ path: file, version, mode: "document" })
  179. await client.connection.sendNotification("test/publish-diagnostics", {
  180. uri: pathToFileURL(file).href,
  181. version,
  182. diagnostics: [
  183. {
  184. range: {
  185. start: { line: 0, character: 0 },
  186. end: { line: 0, character: 5 },
  187. },
  188. message: "push diagnostic",
  189. severity: 1,
  190. },
  191. ],
  192. })
  193. await wait
  194. const diagnostics = client.diagnostics.get(file) ?? []
  195. expect(diagnostics).toHaveLength(1)
  196. expect(diagnostics[0]?.message).toBe("push diagnostic")
  197. const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {})
  198. expect(count).toBe(0)
  199. await client.shutdown()
  200. },
  201. })
  202. })
  203. test("document mode accepts matching push diagnostics published before waiting", async () => {
  204. const handle = spawnFakeServer() as any
  205. await using tmp = await tmpdir()
  206. const file = path.join(tmp.path, "client.ts")
  207. await Bun.write(file, "const x = 1\n")
  208. await withTestInstance({
  209. directory: tmp.path,
  210. fn: async (ctx) => {
  211. const client = await LSPClient.create({
  212. serverID: "fake",
  213. server: handle as unknown as LSPServer.Handle,
  214. root: tmp.path,
  215. directory: tmp.path,
  216. instance: ctx,
  217. })
  218. const version = await client.notify.open({ path: file })
  219. await client.connection.sendNotification("test/publish-diagnostics", {
  220. uri: pathToFileURL(file).href,
  221. version,
  222. diagnostics: [
  223. {
  224. range: {
  225. start: { line: 0, character: 0 },
  226. end: { line: 0, character: 5 },
  227. },
  228. message: "push diagnostic",
  229. severity: 1,
  230. },
  231. ],
  232. })
  233. for (let i = 0; i < 20 && (client.diagnostics.get(file)?.length ?? 0) === 0; i++) {
  234. await new Promise((resolve) => setTimeout(resolve, 25))
  235. }
  236. expect(client.diagnostics.get(file)?.[0]?.message).toBe("push diagnostic")
  237. const started = Date.now()
  238. await client.waitForDiagnostics({ path: file, version, mode: "document" })
  239. expect(Date.now() - started).toBeLessThan(1_000)
  240. await client.shutdown()
  241. },
  242. })
  243. })
  244. test("document mode waits for pull diagnostics", async () => {
  245. const handle = spawnFakeServer() as any
  246. await using tmp = await tmpdir()
  247. const file = path.join(tmp.path, "client.cs")
  248. await Bun.write(file, "class C {}\n")
  249. await withTestInstance({
  250. directory: tmp.path,
  251. fn: async (ctx) => {
  252. const client = await LSPClient.create({
  253. serverID: "fake",
  254. server: handle as unknown as LSPServer.Handle,
  255. root: tmp.path,
  256. directory: tmp.path,
  257. instance: ctx,
  258. })
  259. await client.connection.sendRequest("test/configure-pull-diagnostics", {
  260. registerOn: "didOpen",
  261. registrations: [{ identifier: "DocumentCompilerSemantic" }],
  262. documentDiagnosticsByIdentifier: {
  263. DocumentCompilerSemantic: [
  264. {
  265. range: {
  266. start: { line: 0, character: 0 },
  267. end: { line: 0, character: 5 },
  268. },
  269. message: "pull diagnostic",
  270. severity: 1,
  271. },
  272. ],
  273. },
  274. })
  275. const version = await client.notify.open({ path: file })
  276. await client.waitForDiagnostics({ path: file, version, mode: "document" })
  277. const diagnostics = client.diagnostics.get(file) ?? []
  278. expect(diagnostics).toHaveLength(1)
  279. expect(diagnostics[0]?.message).toBe("pull diagnostic")
  280. const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {})
  281. expect(count).toBeGreaterThan(0)
  282. await client.shutdown()
  283. },
  284. })
  285. })
  286. test("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", async () => {
  287. const handle = spawnFakeServer() as any
  288. await using tmp = await tmpdir()
  289. const file = path.join(tmp.path, "client.cs")
  290. await Bun.write(file, "class C {}\n")
  291. await withTestInstance({
  292. directory: tmp.path,
  293. fn: async (ctx) => {
  294. const client = await LSPClient.create({
  295. serverID: "fake",
  296. server: handle as unknown as LSPServer.Handle,
  297. root: tmp.path,
  298. directory: tmp.path,
  299. instance: ctx,
  300. })
  301. await client.connection.sendRequest("test/configure-pull-diagnostics", {
  302. registrations: [{ identifier: "fast" }, { identifier: "slow" }],
  303. documentDiagnosticsByIdentifier: {
  304. fast: [
  305. {
  306. range: {
  307. start: { line: 0, character: 0 },
  308. end: { line: 0, character: 5 },
  309. },
  310. message: "fast diagnostic",
  311. severity: 1,
  312. },
  313. ],
  314. slow: [],
  315. },
  316. documentDelayMsByIdentifier: {
  317. slow: 2_500,
  318. },
  319. })
  320. const version = await client.notify.open({ path: file })
  321. await client.connection.sendRequest("test/register-configured-pull-diagnostics", {})
  322. await new Promise((resolve) => setTimeout(resolve, 100))
  323. const started = Date.now()
  324. await client.waitForDiagnostics({ path: file, version, mode: "document" })
  325. expect(Date.now() - started).toBeLessThan(1_000)
  326. expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic")
  327. expect(await client.connection.sendRequest("test/get-diagnostic-request-count", {})).toBeGreaterThan(1)
  328. await client.shutdown()
  329. },
  330. })
  331. })
  332. test("full mode includes workspace pull diagnostics", async () => {
  333. const handle = spawnFakeServer() as any
  334. await using tmp = await tmpdir()
  335. const file = path.join(tmp.path, "client.cs")
  336. const related = path.join(tmp.path, "other.cs")
  337. await Bun.write(file, "class C {}\n")
  338. await Bun.write(related, "class D {}\n")
  339. await withTestInstance({
  340. directory: tmp.path,
  341. fn: async (ctx) => {
  342. const client = await LSPClient.create({
  343. serverID: "fake",
  344. server: handle as unknown as LSPServer.Handle,
  345. root: tmp.path,
  346. directory: tmp.path,
  347. instance: ctx,
  348. })
  349. await client.connection.sendRequest("test/configure-pull-diagnostics", {
  350. registerOn: "didOpen",
  351. registrations: [
  352. { identifier: "DocumentCompilerSemantic" },
  353. { identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true },
  354. ],
  355. documentDiagnosticsByIdentifier: {
  356. DocumentCompilerSemantic: [
  357. {
  358. range: {
  359. start: { line: 0, character: 0 },
  360. end: { line: 0, character: 5 },
  361. },
  362. message: "current file",
  363. severity: 1,
  364. },
  365. ],
  366. },
  367. workspaceDiagnosticsByIdentifier: {
  368. WorkspaceDocumentsAndProject: [
  369. {
  370. uri: pathToFileURL(related).href,
  371. items: [
  372. {
  373. range: {
  374. start: { line: 0, character: 0 },
  375. end: { line: 0, character: 5 },
  376. },
  377. message: "workspace file",
  378. severity: 1,
  379. },
  380. ],
  381. },
  382. ],
  383. },
  384. })
  385. const version = await client.notify.open({ path: file })
  386. await client.waitForDiagnostics({ path: file, version, mode: "full" })
  387. expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file")
  388. expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file")
  389. await client.shutdown()
  390. },
  391. })
  392. })
  393. test("full mode treats an empty workspace pull response as handled", async () => {
  394. const handle = spawnFakeServer() as any
  395. await using tmp = await tmpdir()
  396. const file = path.join(tmp.path, "client.cs")
  397. await Bun.write(file, "class C {}\n")
  398. await withTestInstance({
  399. directory: tmp.path,
  400. fn: async (ctx) => {
  401. const client = await LSPClient.create({
  402. serverID: "fake",
  403. server: handle as unknown as LSPServer.Handle,
  404. root: tmp.path,
  405. directory: tmp.path,
  406. instance: ctx,
  407. })
  408. await client.connection.sendRequest("test/configure-pull-diagnostics", {
  409. registerOn: "didOpen",
  410. registrations: [{ identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }],
  411. workspaceDiagnosticsByIdentifier: {
  412. WorkspaceDocumentsAndProject: [],
  413. },
  414. })
  415. const version = await client.notify.open({ path: file })
  416. const started = Date.now()
  417. await client.waitForDiagnostics({ path: file, version, mode: "full" })
  418. expect(Date.now() - started).toBeLessThan(1_000)
  419. await client.shutdown()
  420. },
  421. })
  422. })
  423. })