@marimo-team/frontend 0.23.16-dev34 → 0.23.16-dev36

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.
Files changed (105) hide show
  1. package/dist/assets/{CellStatus-CmojVYzm.js → CellStatus-Dua-Q5qp.js} +1 -1
  2. package/dist/assets/{JsonOutput-BwwRpByA.js → JsonOutput-BjTsPSAI.js} +12 -12
  3. package/dist/assets/{MarimoErrorOutput-BWte8vjk.js → MarimoErrorOutput-BSyqD6Hg.js} +8 -8
  4. package/dist/assets/{RenderHTML-DRltV71H.js → RenderHTML-DG0Fm01X.js} +1 -1
  5. package/dist/assets/{RunButton-DIegd2mN.js → RunButton-DFs1N-c9.js} +1 -1
  6. package/dist/assets/{add-cell-with-ai-BBFD-M9V.js → add-cell-with-ai-C6Kxfu_D.js} +17 -17
  7. package/dist/assets/{add-connection-dialog-CECJ8507.js → add-connection-dialog-DAqIrB2O.js} +1 -1
  8. package/dist/assets/{agent-panel-YaS0WXP1.js → agent-panel-GtkUm7qc.js} +5 -5
  9. package/dist/assets/ai-model-dropdown-UEHQ_Tx0.js +5 -0
  10. package/dist/assets/{app-config-button-BW-w01aF.js → app-config-button-BmBlROBo.js} +1 -1
  11. package/dist/assets/{cell-editor-EV-1IccQ.js → cell-editor-DKzBZjFa.js} +10 -10
  12. package/dist/assets/{cell-link-C5gpAn3R.js → cell-link-CRHwRCw2.js} +1 -1
  13. package/dist/assets/cells-C-9Qa_6_.js +235 -0
  14. package/dist/assets/{chat-display-8BbC5B-D.js → chat-display-CqB9H1kF.js} +1 -1
  15. package/dist/assets/{chat-panel-CMOZp5xW.js → chat-panel-C3wpuHtk.js} +2 -2
  16. package/dist/assets/{chat-ui-DWhJ2Ozy.js → chat-ui-DF2nZlBy.js} +1 -1
  17. package/dist/assets/{column-preview-DeXESYpM.js → column-preview-Bp4QHPE2.js} +1 -1
  18. package/dist/assets/{command-palette-Dx6AkiPd.js → command-palette-DaJdKaQz.js} +1 -1
  19. package/dist/assets/{common-XVELED_O.js → common-DeheUlBO.js} +1 -1
  20. package/dist/assets/{components-BLHpmhXx.js → components-Dk-7Tn_8.js} +1 -1
  21. package/dist/assets/{datasource-CTqa8bf-.js → datasource-D-oZZRFN.js} +2 -2
  22. package/dist/assets/{dependency-graph-panel-C4Nzcddp.js → dependency-graph-panel-JK4qjNID.js} +3 -3
  23. package/dist/assets/{documentation-panel-Bym4G7IS.js → documentation-panel-C_TxLeX8.js} +1 -1
  24. package/dist/assets/{download-HKgqJX4l.js → download-QhwPYWYu.js} +2 -2
  25. package/dist/assets/{edit-page-Jtp6Qqjl.js → edit-page-C8yy6X_7.js} +6 -6
  26. package/dist/assets/{error-entries-CxyvaxEM.js → error-entries-B8hPG61B.js} +2 -2
  27. package/dist/assets/{error-panel-CfSy7uUr.js → error-panel-BOBOm9wN.js} +1 -1
  28. package/dist/assets/{file-explorer-panel-Dd5Xn2TK.js → file-explorer-panel-CQOFpuIG.js} +4 -4
  29. package/dist/assets/{file-icons-CSjJ3JC6.js → file-icons-DoBeqg86.js} +1 -1
  30. package/dist/assets/{file-name-input-CrKtrXMl.js → file-name-input-B8d-QPzY.js} +1 -1
  31. package/dist/assets/{floating-outline-ByQ8vvif.js → floating-outline-ylNOBTvA.js} +1 -1
  32. package/dist/assets/{focus-CVONl-Dy.js → focus-CJzSyxLL.js} +1 -1
  33. package/dist/assets/{form-Ca3yd_Od.js → form-C2_p_FeE.js} +2 -2
  34. package/dist/assets/{home-page-FpAawj9s.js → home-page-D_6eKuGc.js} +2 -2
  35. package/dist/assets/{html-to-image-C-4ysbGe.js → html-to-image-DfyxinVN.js} +2 -2
  36. package/dist/assets/{index-CiJmYQBT.js → index-D-wPCBTa.js} +7 -7
  37. package/dist/assets/{index-xs-zSSrX.css → index-D6Dp_PFj.css} +1 -1
  38. package/dist/assets/{layout-Dtql_o_T.js → layout-BWV4OadK.js} +5 -5
  39. package/dist/assets/logs-panel-DpMLUEOB.js +1 -0
  40. package/dist/assets/{markdown-renderer-dABDqZDw.js → markdown-renderer-DtzxgcfB.js} +2 -2
  41. package/dist/assets/{name-cell-input-Y4knmEIU.js → name-cell-input-SbknxDvi.js} +1 -1
  42. package/dist/assets/{outline-panel-CQ_KWLip.js → outline-panel-kV7ARoXE.js} +1 -1
  43. package/dist/assets/{packages-panel-Ijt7d51P.js → packages-panel-B3biBPQ1.js} +1 -1
  44. package/dist/assets/{panels-DfU1Oikj.js → panels-BsrSW0bC.js} +2 -2
  45. package/dist/assets/process-output-DCAOrKEU.js +1 -0
  46. package/dist/assets/{radio-group-22eWJ9fv.js → radio-group-BWAhJtjV.js} +1 -1
  47. package/dist/assets/readonly-code-display-D_rgEfAm.js +1 -0
  48. package/dist/assets/{readonly-python-code-n_TrLmsR.js → readonly-python-code-B5ABqdqK.js} +1 -1
  49. package/dist/assets/{reveal-component-CCLTV1q6.js → reveal-component-CrcdxGl_.js} +2 -2
  50. package/dist/assets/{run-page-DbezK7Qs.js → run-page-DFHpKr66.js} +1 -1
  51. package/dist/assets/{scratchpad-panel-C2wAEv5n.js → scratchpad-panel-CfqnZK3C.js} +1 -1
  52. package/dist/assets/{session-panel-BZGNcZmp.js → session-panel-DWS61Z1x.js} +1 -1
  53. package/dist/assets/{skeleton-BzlKcq21.js → skeleton-PJnQ2_IA.js} +1 -1
  54. package/dist/assets/{snippets-panel-DNaNtkR0.js → snippets-panel-D1eovB0O.js} +1 -1
  55. package/dist/assets/{state-jCdh8Dcx.js → state-BjouLTuw.js} +1 -1
  56. package/dist/assets/{state-DFPAfLw7.js → state-Bu-aMpIc.js} +3 -3
  57. package/dist/assets/{textarea-DuEtSOCl.js → textarea-DhPaQ0wx.js} +1 -1
  58. package/dist/assets/{tracing-ttUd3J7U.js → tracing-B2ux60rQ.js} +1 -1
  59. package/dist/assets/{tracing-panel-rVAa3En0.js → tracing-panel-CF8H5wRv.js} +2 -2
  60. package/dist/assets/{tree-actions-UCr1tMHw.js → tree-actions-CPSDN4eE.js} +1 -1
  61. package/dist/assets/useCellActionButton-k2OXllYI.js +1 -0
  62. package/dist/assets/{useDeleteCell-PrgejW8A.js → useDeleteCell-C3nmnuxK.js} +1 -1
  63. package/dist/assets/{useDependencyPanelTab-5jc-_-hy.js → useDependencyPanelTab-D14Lna3z.js} +1 -1
  64. package/dist/assets/{useNotebookActions-4vw_NiRs.js → useNotebookActions-CHM0Ardg.js} +3 -3
  65. package/dist/assets/useRunCells-C4qD32Ix.js +1 -0
  66. package/dist/assets/useSplitCell-BEebdldD.js +1 -0
  67. package/dist/index.html +23 -23
  68. package/package.json +2 -3
  69. package/src/components/editor/chrome/panels/__tests__/logs-panel.test.tsx +25 -0
  70. package/src/components/editor/chrome/panels/logs-panel.tsx +12 -2
  71. package/src/core/cells/__tests__/cells.test.ts +12 -2
  72. package/src/core/cells/__tests__/logs.test.ts +5 -5
  73. package/src/core/cells/cells.ts +13 -0
  74. package/src/core/cells/document-changes.ts +1 -0
  75. package/src/core/cells/logs.ts +14 -4
  76. package/src/core/codemirror/copilot/__tests__/language-server.test.ts +396 -100
  77. package/src/core/codemirror/copilot/__tests__/transport.test.ts +128 -540
  78. package/src/core/codemirror/copilot/language-server.ts +182 -70
  79. package/src/core/codemirror/copilot/transport.ts +15 -207
  80. package/src/core/codemirror/copilot/types.ts +9 -2
  81. package/src/core/codemirror/language/languages/python.ts +23 -11
  82. package/src/core/codemirror/lsp/__tests__/federated-lsp.test.ts +161 -0
  83. package/src/core/codemirror/lsp/__tests__/log-messages.test.ts +53 -0
  84. package/src/core/codemirror/lsp/__tests__/normalize-markdown-math.test.ts +25 -0
  85. package/src/core/codemirror/lsp/__tests__/notebook-lsp.test.ts +598 -156
  86. package/src/core/codemirror/lsp/__tests__/transports.test.ts +112 -0
  87. package/src/core/codemirror/lsp/federated-lsp.ts +76 -23
  88. package/src/core/codemirror/lsp/log-messages.ts +37 -0
  89. package/src/core/codemirror/lsp/normalize-markdown-math.ts +8 -4
  90. package/src/core/codemirror/lsp/notebook-lsp.ts +220 -56
  91. package/src/core/codemirror/lsp/transports.ts +8 -1
  92. package/src/core/codemirror/lsp/types.ts +68 -8
  93. package/src/core/codemirror/lsp/utils.ts +14 -0
  94. package/src/core/lsp/__tests__/transport.test.ts +509 -337
  95. package/src/core/lsp/transport.ts +304 -132
  96. package/src/css/app/codemirror.css +16 -2
  97. package/src/stories/log-viewer.stories.tsx +12 -6
  98. package/dist/assets/ai-model-dropdown-BpVBUZnz.js +0 -5
  99. package/dist/assets/cells-6yd8nOAu.js +0 -231
  100. package/dist/assets/logs-panel-5u1wUBn1.js +0 -1
  101. package/dist/assets/process-output-Dd11YI04.js +0 -1
  102. package/dist/assets/readonly-code-display-BjK-TEri.js +0 -1
  103. package/dist/assets/useCellActionButton-CJknR0XY.js +0 -1
  104. package/dist/assets/useRunCells-DHsd7HGL.js +0 -1
  105. package/dist/assets/useSplitCell-MZpER0Bh.js +0 -1
@@ -1,28 +1,81 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- /* oxlint-disable typescript/no-explicit-any */
3
-
4
- import { Transport } from "@open-rpc/client-js/build/transports/Transport";
2
+ import type {
3
+ JSONRPCMessage,
4
+ JSONRPCNotification,
5
+ JSONRPCRequest,
6
+ Transport,
7
+ } from "@marimo-team/codemirror-languageserver";
5
8
  import { beforeEach, describe, expect, it, vi } from "vitest";
9
+ import { store } from "@/core/state/jotai";
6
10
  import { CopilotLanguageServerClient } from "../language-server";
11
+ import { copilotStatusState } from "../state";
12
+
13
+ const copilotState = vi.hoisted(() => ({ isEnabled: true }));
14
+
15
+ vi.mock("../state", async (importOriginal) => {
16
+ const actual = await importOriginal<typeof import("../state")>();
17
+ return {
18
+ ...actual,
19
+ isCopilotEnabled: () => copilotState.isEnabled,
20
+ };
21
+ });
22
+
23
+ class MockTransport implements Transport {
24
+ private readonly handlers = new Set<(message: JSONRPCMessage) => void>();
25
+ readonly sent: JSONRPCMessage[] = [];
26
+
27
+ connect = vi.fn().mockResolvedValue(undefined);
28
+ close = vi.fn();
29
+ send = vi.fn((message: JSONRPCMessage) => {
30
+ this.sent.push(message);
31
+ if (isRequest(message) && message.method === "initialize") {
32
+ queueMicrotask(() => {
33
+ this.emit({
34
+ jsonrpc: "2.0",
35
+ id: message.id,
36
+ result: { capabilities: {} },
37
+ });
38
+ });
39
+ }
40
+ });
41
+
42
+ onMessage(handler: (message: JSONRPCMessage) => void): () => void {
43
+ this.handlers.add(handler);
44
+ return () => {
45
+ this.handlers.delete(handler);
46
+ };
47
+ }
48
+
49
+ emit(message: JSONRPCMessage) {
50
+ for (const handler of this.handlers) {
51
+ handler(message);
52
+ }
53
+ }
54
+ }
7
55
 
8
- // Mock transport
9
- class MockTransport extends Transport {
10
- override connect = vi.fn().mockResolvedValue(undefined);
11
- override close = vi.fn();
12
- override sendData = vi.fn().mockResolvedValue({});
13
- override subscribe = vi.fn();
14
- override unsubscribe = vi.fn();
15
- override parseData = vi.fn();
56
+ function isRequest(message: JSONRPCMessage): message is JSONRPCRequest {
57
+ return "method" in message && "id" in message;
58
+ }
59
+
60
+ function notifications(
61
+ transport: MockTransport,
62
+ method: string,
63
+ ): JSONRPCNotification[] {
64
+ return transport.sent.filter(
65
+ (message): message is JSONRPCNotification =>
66
+ "method" in message && !("id" in message) && message.method === method,
67
+ );
16
68
  }
17
69
 
18
70
  describe("CopilotLanguageServerClient", () => {
19
71
  let mockTransport: MockTransport;
20
72
 
21
73
  beforeEach(() => {
74
+ copilotState.isEnabled = true;
22
75
  mockTransport = new MockTransport();
23
76
  });
24
77
 
25
- it("should initialize without copilot settings", () => {
78
+ it("initializes without copilot settings", () => {
26
79
  const client = new CopilotLanguageServerClient({
27
80
  rootUri: "file:///test",
28
81
  workspaceFolders: null,
@@ -32,178 +85,421 @@ describe("CopilotLanguageServerClient", () => {
32
85
  expect(client).toBeDefined();
33
86
  });
34
87
 
35
- it("should initialize with empty copilot settings", () => {
88
+ it("initializes with copilot settings", () => {
36
89
  const client = new CopilotLanguageServerClient({
37
90
  rootUri: "file:///test",
38
91
  workspaceFolders: null,
39
92
  transport: mockTransport,
40
- copilotSettings: {},
93
+ copilotSettings: {
94
+ http: {
95
+ proxy: "http://proxy.example.com:8888",
96
+ proxyStrictSSL: true,
97
+ },
98
+ telemetry: {
99
+ telemetryLevel: "off",
100
+ },
101
+ },
41
102
  });
42
103
 
43
104
  expect(client).toBeDefined();
44
105
  });
45
106
 
46
- it("should initialize with copilot settings", () => {
107
+ it("sends configuration after initialization", async () => {
47
108
  const copilotSettings = {
48
109
  http: {
49
110
  proxy: "http://proxy.example.com:8888",
50
111
  proxyStrictSSL: true,
51
112
  },
52
- telemetry: {
53
- telemetryLevel: "off",
54
- },
55
113
  };
56
-
57
114
  const client = new CopilotLanguageServerClient({
58
115
  rootUri: "file:///test",
59
116
  workspaceFolders: null,
60
117
  transport: mockTransport,
61
118
  copilotSettings,
62
119
  });
63
-
64
- expect(client).toBeDefined();
120
+ await client.initializePromise;
121
+ await vi.waitFor(() => {
122
+ expect(
123
+ notifications(mockTransport, "workspace/didChangeConfiguration"),
124
+ ).toEqual([
125
+ {
126
+ jsonrpc: "2.0",
127
+ method: "workspace/didChangeConfiguration",
128
+ params: { settings: copilotSettings },
129
+ },
130
+ ]);
131
+ });
65
132
  });
66
133
 
67
- it("should send configuration after initialization when settings are provided", async () => {
134
+ it.each([
135
+ ["empty", {}],
136
+ ["omitted", undefined],
137
+ ])(
138
+ "does not send configuration when settings are %s",
139
+ async (_, copilotSettings) => {
140
+ const client = new CopilotLanguageServerClient({
141
+ rootUri: "file:///test",
142
+ workspaceFolders: null,
143
+ transport: mockTransport,
144
+ copilotSettings,
145
+ });
146
+ await client.initializePromise;
147
+ await new Promise((resolve) => setTimeout(resolve, 0));
148
+
149
+ expect(
150
+ notifications(mockTransport, "workspace/didChangeConfiguration"),
151
+ ).toEqual([]);
152
+ },
153
+ );
154
+
155
+ it("accepts enterprise, proxy, and telemetry settings together", async () => {
68
156
  const copilotSettings = {
69
157
  http: {
70
158
  proxy: "http://proxy.example.com:8888",
71
159
  proxyStrictSSL: true,
160
+ proxyKerberosServicePrincipal: "HTTP/proxy.example.com",
161
+ },
162
+ telemetry: {
163
+ telemetryLevel: "all",
164
+ },
165
+ "github-enterprise": {
166
+ uri: "https://github.enterprise.com",
72
167
  },
73
168
  };
74
-
75
169
  const client = new CopilotLanguageServerClient({
76
170
  rootUri: "file:///test",
77
171
  workspaceFolders: null,
78
172
  transport: mockTransport,
79
173
  copilotSettings,
80
174
  });
81
-
82
- // Spy on the notify method
83
- const notifySpy = vi.spyOn(client as any, "notify");
84
-
85
- // Wait for initialization
86
175
  await client.initializePromise;
87
-
88
- // Wait for next tick to let the configuration be sent
89
- await new Promise((resolve) => setTimeout(resolve, 0));
90
-
91
- // Verify that notify was called with the correct parameters
92
- expect(notifySpy).toHaveBeenCalledWith("workspace/didChangeConfiguration", {
93
- settings: copilotSettings,
176
+ await vi.waitFor(() => {
177
+ expect(
178
+ notifications(mockTransport, "workspace/didChangeConfiguration"),
179
+ ).toEqual([
180
+ {
181
+ jsonrpc: "2.0",
182
+ method: "workspace/didChangeConfiguration",
183
+ params: { settings: copilotSettings },
184
+ },
185
+ ]);
94
186
  });
95
187
  });
96
188
 
97
- it("should not send configuration after initialization when settings are empty", async () => {
189
+ it("reopens the latest Copilot document after re-initialization", async () => {
98
190
  const client = new CopilotLanguageServerClient({
99
191
  rootUri: "file:///test",
100
192
  workspaceFolders: null,
101
193
  transport: mockTransport,
102
- copilotSettings: {},
103
194
  });
195
+ await client.initializePromise;
196
+ await client.textDocumentDidOpen({
197
+ textDocument: {
198
+ uri: "file:///test.py",
199
+ languageId: "python",
200
+ version: 0,
201
+ text: "initial",
202
+ },
203
+ });
204
+ await client.textDocumentDidChange({
205
+ textDocument: { uri: "file:///test.py", version: 1 },
206
+ contentChanges: [{ text: "updated" }],
207
+ });
208
+ mockTransport.sent.length = 0;
209
+
210
+ await client.reInitialize();
211
+
212
+ expect(notifications(mockTransport, "textDocument/didOpen")).toEqual([
213
+ {
214
+ jsonrpc: "2.0",
215
+ method: "textDocument/didOpen",
216
+ params: {
217
+ textDocument: {
218
+ uri: "file:///test.py",
219
+ languageId: "python",
220
+ version: 1,
221
+ text: "updated",
222
+ },
223
+ },
224
+ },
225
+ ]);
226
+ });
104
227
 
105
- // Spy on the notify method
106
- const notifySpy = vi.spyOn(client as any, "notify");
228
+ it("reopens the document with the languageId it was opened with", async () => {
229
+ const client = new CopilotLanguageServerClient({
230
+ rootUri: "file:///test",
231
+ workspaceFolders: null,
232
+ transport: mockTransport,
233
+ });
234
+ await client.initializePromise;
235
+ // The Copilot plugin opens as "copilot", not "python"
236
+ await client.textDocumentDidOpen({
237
+ textDocument: {
238
+ uri: "file:///test.py",
239
+ languageId: "copilot",
240
+ version: 0,
241
+ text: "initial",
242
+ },
243
+ });
244
+ await client.textDocumentDidChange({
245
+ textDocument: { uri: "file:///test.py", version: 1 },
246
+ contentChanges: [{ text: "updated" }],
247
+ });
248
+ mockTransport.sent.length = 0;
249
+
250
+ await client.reInitialize();
251
+
252
+ expect(notifications(mockTransport, "textDocument/didOpen")).toEqual([
253
+ {
254
+ jsonrpc: "2.0",
255
+ method: "textDocument/didOpen",
256
+ params: {
257
+ textDocument: {
258
+ uri: "file:///test.py",
259
+ languageId: "copilot",
260
+ version: 1,
261
+ text: "updated",
262
+ },
263
+ },
264
+ },
265
+ ]);
266
+ });
107
267
 
108
- // Wait for initialization
268
+ it("ignores an empty didChange without corrupting document state", async () => {
269
+ const client = new CopilotLanguageServerClient({
270
+ rootUri: "file:///test",
271
+ workspaceFolders: null,
272
+ transport: mockTransport,
273
+ });
109
274
  await client.initializePromise;
275
+ mockTransport.sent.length = 0;
110
276
 
111
- // Wait for next tick
112
- await new Promise((resolve) => setTimeout(resolve, 0));
277
+ await expect(
278
+ client.textDocumentDidChange({
279
+ textDocument: { uri: "file:///test.py", version: 1 },
280
+ contentChanges: [],
281
+ }),
282
+ ).resolves.toBeUndefined();
113
283
 
114
- // Verify that notify was NOT called with didChangeConfiguration
115
- expect(notifySpy).not.toHaveBeenCalledWith(
116
- "workspace/didChangeConfiguration",
117
- expect.anything(),
118
- );
284
+ expect(notifications(mockTransport, "textDocument/didOpen")).toEqual([]);
285
+ expect(notifications(mockTransport, "textDocument/didChange")).toEqual([]);
119
286
  });
120
287
 
121
- it("should not send configuration after initialization when settings are not provided", async () => {
288
+ it("balances document references when Copilot is disabled before close", async () => {
122
289
  const client = new CopilotLanguageServerClient({
123
290
  rootUri: "file:///test",
124
291
  workspaceFolders: null,
125
292
  transport: mockTransport,
126
293
  });
127
-
128
- // Spy on the notify method
129
- const notifySpy = vi.spyOn(client as any, "notify");
130
-
131
- // Wait for initialization
132
294
  await client.initializePromise;
295
+ await client.textDocumentDidOpen({
296
+ textDocument: {
297
+ uri: "file:///test.py",
298
+ languageId: "python",
299
+ version: 7,
300
+ text: "value = 1",
301
+ },
302
+ });
133
303
 
134
- // Wait for next tick
135
- await new Promise((resolve) => setTimeout(resolve, 0));
304
+ copilotState.isEnabled = false;
305
+ await client.textDocumentDidClose({
306
+ textDocument: { uri: "file:///test.py" },
307
+ });
308
+ copilotState.isEnabled = true;
309
+ mockTransport.sent.length = 0;
310
+ await client.reInitialize();
136
311
 
137
- // Verify that notify was NOT called with didChangeConfiguration
138
- expect(notifySpy).not.toHaveBeenCalledWith(
139
- "workspace/didChangeConfiguration",
140
- expect.anything(),
141
- );
312
+ expect(notifications(mockTransport, "textDocument/didOpen")).toEqual([]);
142
313
  });
143
314
 
144
- it("should accept github-enterprise settings", async () => {
145
- const copilotSettings = {
146
- "github-enterprise": {
147
- uri: "https://github.enterprise.com",
148
- },
149
- };
150
-
315
+ it("does not close the shared document until the last mounted editor closes it (relies on base client's own refcounting)", async () => {
151
316
  const client = new CopilotLanguageServerClient({
152
317
  rootUri: "file:///test",
153
318
  workspaceFolders: null,
154
319
  transport: mockTransport,
155
- copilotSettings,
156
320
  });
157
-
158
- // Spy on the notify method
159
- const notifySpy = vi.spyOn(client as any, "notify");
160
-
161
- // Wait for initialization
162
321
  await client.initializePromise;
163
322
 
164
- // Wait for next tick to let the configuration be sent
165
- await new Promise((resolve) => setTimeout(resolve, 0));
323
+ // Two editors (e.g. two visible cells) share this client and both open
324
+ // the same document.
325
+ await client.textDocumentDidOpen({
326
+ textDocument: {
327
+ uri: "file:///test.py",
328
+ languageId: "python",
329
+ version: 1,
330
+ text: "value = 1",
331
+ },
332
+ });
333
+ await client.textDocumentDidOpen({
334
+ textDocument: {
335
+ uri: "file:///test.py",
336
+ languageId: "python",
337
+ version: 1,
338
+ text: "value = 1",
339
+ },
340
+ });
341
+ mockTransport.sent.length = 0;
166
342
 
167
- // Verify that notify was called with the enterprise settings
168
- expect(notifySpy).toHaveBeenCalledWith("workspace/didChangeConfiguration", {
169
- settings: copilotSettings,
343
+ // Closing the first editor must not close the shared document while the
344
+ // second editor is still mounted.
345
+ await client.textDocumentDidClose({
346
+ textDocument: { uri: "file:///test.py" },
170
347
  });
171
- });
348
+ expect(notifications(mockTransport, "textDocument/didClose")).toEqual([]);
172
349
 
173
- it("should accept all supported settings at once", async () => {
174
- const copilotSettings = {
175
- http: {
176
- proxy: "http://proxy.example.com:8888",
177
- proxyStrictSSL: true,
178
- proxyKerberosServicePrincipal: "HTTP/proxy.example.com",
350
+ // Closing the last editor closes the document for real.
351
+ await client.textDocumentDidClose({
352
+ textDocument: { uri: "file:///test.py" },
353
+ });
354
+ expect(notifications(mockTransport, "textDocument/didClose")).toEqual([
355
+ {
356
+ jsonrpc: "2.0",
357
+ method: "textDocument/didClose",
358
+ params: { textDocument: { uri: "file:///test.py" } },
179
359
  },
180
- telemetry: {
181
- telemetryLevel: "all",
360
+ ]);
361
+ });
362
+
363
+ it("keeps document versions monotonic after opening a versioned document", async () => {
364
+ const client = new CopilotLanguageServerClient({
365
+ rootUri: "file:///test",
366
+ workspaceFolders: null,
367
+ transport: mockTransport,
368
+ });
369
+ await client.initializePromise;
370
+ await client.textDocumentDidOpen({
371
+ textDocument: {
372
+ uri: "file:///test.py",
373
+ languageId: "python",
374
+ version: 7,
375
+ text: "value = 1",
182
376
  },
183
- "github-enterprise": {
184
- uri: "https://github.enterprise.com",
377
+ });
378
+ mockTransport.sent.length = 0;
379
+
380
+ await client.textDocumentDidChange({
381
+ textDocument: { uri: "file:///test.py", version: 8 },
382
+ contentChanges: [{ text: "value = 2" }],
383
+ });
384
+
385
+ expect(notifications(mockTransport, "textDocument/didChange")).toEqual([
386
+ {
387
+ jsonrpc: "2.0",
388
+ method: "textDocument/didChange",
389
+ params: {
390
+ textDocument: { uri: "file:///test.py", version: 8 },
391
+ contentChanges: [{ text: "value = 2" }],
392
+ },
185
393
  },
186
- };
394
+ ]);
395
+ });
187
396
 
397
+ it("ignores malformed custom notifications", async () => {
188
398
  const client = new CopilotLanguageServerClient({
189
399
  rootUri: "file:///test",
190
400
  workspaceFolders: null,
191
401
  transport: mockTransport,
192
- copilotSettings,
193
402
  });
403
+ await client.initializePromise;
194
404
 
195
- // Spy on the notify method
196
- const notifySpy = vi.spyOn(client as any, "notify");
405
+ expect(() => {
406
+ mockTransport.emit({
407
+ jsonrpc: "2.0",
408
+ method: "statusNotification",
409
+ params: { busy: "not-a-boolean" },
410
+ });
411
+ mockTransport.emit({
412
+ jsonrpc: "2.0",
413
+ method: "window/logMessage",
414
+ params: null,
415
+ });
416
+ }).not.toThrow();
417
+ });
197
418
 
198
- // Wait for initialization
199
- await client.initializePromise;
419
+ describe("status notifications", () => {
420
+ beforeEach(() => {
421
+ store.set(copilotStatusState, {
422
+ busy: false,
423
+ kind: null,
424
+ message: null,
425
+ });
426
+ });
200
427
 
201
- // Wait for next tick to let the configuration be sent
202
- await new Promise((resolve) => setTimeout(resolve, 0));
428
+ it.each([
429
+ [
430
+ "omitted message and unknown kind",
431
+ { kind: "Inactive", busy: false },
432
+ { busy: false, kind: "Inactive", message: null, status: undefined },
433
+ ],
434
+ [
435
+ "omitted busy",
436
+ { kind: "Error", message: "Not signed in" },
437
+ {
438
+ busy: false,
439
+ kind: "Error",
440
+ message: "Not signed in",
441
+ status: undefined,
442
+ },
443
+ ],
444
+ [
445
+ "a fully populated payload",
446
+ {
447
+ busy: true,
448
+ kind: "Normal",
449
+ message: "Working",
450
+ status: "InProgress",
451
+ },
452
+ {
453
+ busy: true,
454
+ kind: "Normal",
455
+ message: "Working",
456
+ status: "InProgress",
457
+ },
458
+ ],
459
+ [
460
+ "unusable field types",
461
+ { busy: 1, kind: 2, message: 3, status: 4 },
462
+ { busy: false, kind: null, message: null, status: undefined },
463
+ ],
464
+ ])(
465
+ "normalizes rather than discards a status update with %s",
466
+ async (_label, params, expected) => {
467
+ const client = new CopilotLanguageServerClient({
468
+ rootUri: "file:///test",
469
+ workspaceFolders: null,
470
+ transport: mockTransport,
471
+ });
472
+ await client.initializePromise;
473
+
474
+ mockTransport.emit({
475
+ jsonrpc: "2.0",
476
+ method: "didChangeStatus",
477
+ params,
478
+ });
479
+
480
+ expect(store.get(copilotStatusState)).toEqual(expected);
481
+ },
482
+ );
203
483
 
204
- // Verify that notify was called with all settings
205
- expect(notifySpy).toHaveBeenCalledWith("workspace/didChangeConfiguration", {
206
- settings: copilotSettings,
484
+ it("ignores a status update with non-object params", async () => {
485
+ const client = new CopilotLanguageServerClient({
486
+ rootUri: "file:///test",
487
+ workspaceFolders: null,
488
+ transport: mockTransport,
489
+ });
490
+ await client.initializePromise;
491
+
492
+ mockTransport.emit({
493
+ jsonrpc: "2.0",
494
+ method: "statusNotification",
495
+ params: "not-an-object",
496
+ });
497
+
498
+ expect(store.get(copilotStatusState)).toEqual({
499
+ busy: false,
500
+ kind: null,
501
+ message: null,
502
+ });
207
503
  });
208
504
  });
209
505
  });