client.test.ts 15 KB

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