@marimo-team/frontend 0.25.2-dev114 → 0.25.2-dev115

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 (85) hide show
  1. package/dist/assets/{CellStatus-B4z-D5H1.js → CellStatus-CtjqYbWH.js} +1 -1
  2. package/dist/assets/{ErrorBoundary-R-fjO4Cz.js → ErrorBoundary-F1payLgE.js} +2 -2
  3. package/dist/assets/{JsonOutput-BeLVZDXh.js → JsonOutput-Bhgwb5kV.js} +1 -1
  4. package/dist/assets/{MarimoErrorOutput-B2Ajs_V-.js → MarimoErrorOutput-4jDnOIVr.js} +1 -1
  5. package/dist/assets/{RenderHTML-Chx9oQ4S.js → RenderHTML-B5VPTO14.js} +1 -1
  6. package/dist/assets/{RunButton-DnSopQoo.js → RunButton-BIzGc4XI.js} +1 -1
  7. package/dist/assets/{add-cell-with-ai-9oZmQ0tX.js → add-cell-with-ai-Cmm2fga_.js} +10 -10
  8. package/dist/assets/{add-connection-dialog-C82vMzr5.js → add-connection-dialog-DCpxcHW4.js} +1 -1
  9. package/dist/assets/{agent-panel-8bu-4RMv.js → agent-panel-BsG9PQlJ.js} +5 -5
  10. package/dist/assets/ai-model-dropdown-C4Dg70H_.js +5 -0
  11. package/dist/assets/{api-BJPYk7E_.js → api-BeC_VSGv.js} +1 -1
  12. package/dist/assets/app-config-button-qYCFYvje.js +1 -0
  13. package/dist/assets/{blob-BJi30p8H.js → blob-QvTM0DdL.js} +1 -1
  14. package/dist/assets/{cell-editor-CKwmt_18.js → cell-editor-DdAAUVJx.js} +11 -11
  15. package/dist/assets/{cell-link-BI2FitQ_.js → cell-link-CIHoy1VE.js} +1 -1
  16. package/dist/assets/{cells-D73Y_xmw.js → cells-BWyR7TCJ.js} +1 -1
  17. package/dist/assets/{chat-display-CqK2mMHm.js → chat-display-BzvZ1dG6.js} +1 -1
  18. package/dist/assets/chat-panel-CpLCiU26.js +4 -0
  19. package/dist/assets/{chat-ui-CK-mgi6S.js → chat-ui-t3zRs2SX.js} +1 -1
  20. package/dist/assets/{column-preview-biRDE_Oz.js → column-preview-BJr1QW8R.js} +1 -1
  21. package/dist/assets/command-palette-BQWj0cos.js +1 -0
  22. package/dist/assets/{common-8ezkeamF.js → common-2ZN9KNv-.js} +1 -1
  23. package/dist/assets/{components-FsfAPhUd.js → components-_sJpwVD4.js} +1 -1
  24. package/dist/assets/{datasource-Dniq1d2k.js → datasource-C_fKqG0e.js} +1 -1
  25. package/dist/assets/{dependency-graph-panel-Ci62Tv1x.js → dependency-graph-panel-DSol2xZ3.js} +1 -1
  26. package/dist/assets/{documentation-panel-B-0JfpSn.js → documentation-panel-BcxEbDPv.js} +1 -1
  27. package/dist/assets/{download-CEG5vQri.js → download-B5cBmz55.js} +1 -1
  28. package/dist/assets/{edit-page-VV7jun46.js → edit-page-DMQmQsPo.js} +6 -6
  29. package/dist/assets/{error-entries-Frq0QZL-.js → error-entries-PrZ4UIKa.js} +1 -1
  30. package/dist/assets/{error-panel-BeSB6k3e.js → error-panel-Cuohhvyw.js} +1 -1
  31. package/dist/assets/feedback-button-5oy5W7Kx.js +1 -0
  32. package/dist/assets/{feedback-button-DALE40Cq.js → feedback-button-aEZd8wqo.js} +1 -1
  33. package/dist/assets/{file-explorer-panel-DkhJMce7.js → file-explorer-panel-DMrCYHTB.js} +1 -1
  34. package/dist/assets/{file-name-input-DsLb3kUE.js → file-name-input-CRkt1da3.js} +1 -1
  35. package/dist/assets/{floating-outline-Bivo15Ee.js → floating-outline-BnO_h00w.js} +1 -1
  36. package/dist/assets/{gallery-page-BipSEe8i.js → gallery-page-g3iLOyQO.js} +1 -1
  37. package/dist/assets/{glide-data-editor-DWxXZHhh.js → glide-data-editor-WZMQjVJd.js} +1 -1
  38. package/dist/assets/{home-page-4xyvW9LC.js → home-page-vBOIWWG_.js} +1 -1
  39. package/dist/assets/{hooks-B7y2bbmR.js → hooks-DlgJEnzq.js} +1 -1
  40. package/dist/assets/{index-DLi7reLY.js → index-DTa3czvL.js} +4 -4
  41. package/dist/assets/{layout-B5DZz9ah.js → layout-88dS_AsT.js} +3 -3
  42. package/dist/assets/{logs-panel-CswvaDqE.js → logs-panel-Ct5o61k4.js} +1 -1
  43. package/dist/assets/{markdown-renderer-CErbddaa.js → markdown-renderer-bBVFObv0.js} +1 -1
  44. package/dist/assets/{name-cell-input-DXf9tZ25.js → name-cell-input-RN-BHucn.js} +1 -1
  45. package/dist/assets/{outline-panel-BPlLwpVm.js → outline-panel-B_GILrNI.js} +1 -1
  46. package/dist/assets/{packages-panel-CoIvCGdp.js → packages-panel-r_YSs3uG.js} +1 -1
  47. package/dist/assets/{panel-accordion-state-B50Y_3hb.js → panel-accordion-state-BF3wKXuD.js} +1 -1
  48. package/dist/assets/{panels-j8cWK9B8.js → panels-DNYjDBgD.js} +1 -1
  49. package/dist/assets/{process-output-Bfoxbqpj.js → process-output-C_JmjPkt.js} +1 -1
  50. package/dist/assets/{readonly-code-display-BusLGSPF.js → readonly-code-display-Bsv-J4Xx.js} +1 -1
  51. package/dist/assets/{readonly-python-code-CTbOf95g.js → readonly-python-code-bOYKcbg4.js} +1 -1
  52. package/dist/assets/{reveal-component-D3wlU8v1.js → reveal-component-CLJ6dooc.js} +1 -1
  53. package/dist/assets/{run-page-B03JZs0U.js → run-page-FdfJRz8z.js} +1 -1
  54. package/dist/assets/{runtime-details-state-Ddpsoriz.js → runtime-details-state-ZkKdmmAJ.js} +1 -1
  55. package/dist/assets/{scratchpad-panel-B9RdvhAM.js → scratchpad-panel-D3bf52na.js} +1 -1
  56. package/dist/assets/{session-panel-D3E0Ag30.js → session-panel-DJ2aIlDv.js} +1 -1
  57. package/dist/assets/{snippets-panel-Bby1zIJE.js → snippets-panel-C-lvTkYT.js} +1 -1
  58. package/dist/assets/{state-ENE4B_JH.js → state-C1RQhToR.js} +1 -1
  59. package/dist/assets/{state-zdyODrb0.js → state-DMZ8VJcq.js} +1 -1
  60. package/dist/assets/{tracing-De9k9D6u.js → tracing-f3Wv0MqG.js} +1 -1
  61. package/dist/assets/{tracing-panel-DvISFiEI.js → tracing-panel-BDSbtHAO.js} +2 -2
  62. package/dist/assets/{tree-actions-D5HC2yfb.js → tree-actions-PNciswRG.js} +1 -1
  63. package/dist/assets/{useAddCell-DAJ9Wd-E.js → useAddCell-mcL2PJTv.js} +1 -1
  64. package/dist/assets/{useCellActionButton-CWhycKft.js → useCellActionButton-CYjo2N8X.js} +1 -1
  65. package/dist/assets/{useDeleteCell-8ByYNceO.js → useDeleteCell-hqk8CkzR.js} +1 -1
  66. package/dist/assets/{useDependencyPanelTab-Dz37hZtr.js → useDependencyPanelTab-rJGeCoZQ.js} +1 -1
  67. package/dist/assets/useNotebookActions-BEpiNBZu.js +1 -0
  68. package/dist/assets/{useRunCells-BVoQp_ne.js → useRunCells-DIY1IVXx.js} +1 -1
  69. package/dist/assets/{useSplitCell-Ba_Zm0ID.js → useSplitCell-BGcgAf0Z.js} +1 -1
  70. package/dist/index.html +20 -20
  71. package/package.json +3 -3
  72. package/src/core/codemirror/copilot/__tests__/completion-integration.test.ts +447 -0
  73. package/src/core/codemirror/copilot/__tests__/inline-completion.test.ts +395 -0
  74. package/src/core/codemirror/copilot/__tests__/language-server.test.ts +511 -2
  75. package/src/core/codemirror/copilot/__tests__/rpc-cancellation.test.ts +201 -0
  76. package/src/core/codemirror/copilot/extension.ts +22 -11
  77. package/src/core/codemirror/copilot/language-server.ts +109 -60
  78. package/src/core/network/__tests__/api.test.ts +114 -1
  79. package/src/core/network/api.ts +3 -2
  80. package/dist/assets/ai-model-dropdown-BKQARUae.js +0 -5
  81. package/dist/assets/app-config-button-DKoDBgwu.js +0 -1
  82. package/dist/assets/chat-panel-BgJcxa4q.js +0 -4
  83. package/dist/assets/command-palette-CHUS5_VG.js +0 -1
  84. package/dist/assets/feedback-button-CrqeO2wx.js +0 -1
  85. package/dist/assets/useNotebookActions-CYbHF8MU.js +0 -1
@@ -5,10 +5,12 @@ import type {
5
5
  JSONRPCRequest,
6
6
  Transport,
7
7
  } from "@marimo-team/codemirror-languageserver";
8
- import { beforeEach, describe, expect, it, vi } from "vitest";
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
+ import type { InlineCompletionParams } from "vscode-languageserver-protocol";
9
10
  import { store } from "@/core/state/jotai";
11
+ import * as notebookCodes from "../getCodes";
10
12
  import { CopilotLanguageServerClient } from "../language-server";
11
- import { copilotStatusState } from "../state";
13
+ import { copilotStatusState, githubCopilotLoadingVersion } from "../state";
12
14
 
13
15
  const copilotState = vi.hoisted(() => ({ isEnabled: true }));
14
16
 
@@ -382,6 +384,7 @@ describe("CopilotLanguageServerClient", () => {
382
384
  contentChanges: [{ text: "value = 2" }],
383
385
  });
384
386
 
387
+ await client.flushDocumentChanges();
385
388
  expect(notifications(mockTransport, "textDocument/didChange")).toEqual([
386
389
  {
387
390
  jsonrpc: "2.0",
@@ -503,3 +506,509 @@ describe("CopilotLanguageServerClient", () => {
503
506
  });
504
507
  });
505
508
  });
509
+
510
+ describe("Copilot completion requests", () => {
511
+ let transport: MockTransport;
512
+ let client: CopilotLanguageServerClient;
513
+ const params: InlineCompletionParams = {
514
+ textDocument: { uri: "file:///test.py" },
515
+ position: { line: 0, character: 1 },
516
+ context: { triggerKind: 2 },
517
+ };
518
+ const suggestion = [{ insertText: "result" }];
519
+
520
+ function requests() {
521
+ return transport.sent.filter(
522
+ (message): message is JSONRPCRequest =>
523
+ isRequest(message) &&
524
+ message.method === "textDocument/inlineCompletion",
525
+ );
526
+ }
527
+
528
+ async function change(text: string) {
529
+ await client.textDocumentDidChange({
530
+ textDocument: { uri: params.textDocument.uri, version: 1 },
531
+ contentChanges: [{ text }],
532
+ });
533
+ }
534
+
535
+ async function tick() {
536
+ await vi.advanceTimersByTimeAsync(0);
537
+ }
538
+
539
+ beforeEach(async () => {
540
+ vi.useFakeTimers();
541
+ copilotState.isEnabled = true;
542
+ store.set(githubCopilotLoadingVersion, null);
543
+ transport = new MockTransport();
544
+ client = new CopilotLanguageServerClient({
545
+ rootUri: "file:///test",
546
+ workspaceFolders: null,
547
+ transport,
548
+ });
549
+ await client.initializePromise;
550
+ await client.textDocumentDidOpen({
551
+ textDocument: {
552
+ uri: params.textDocument.uri,
553
+ languageId: "python",
554
+ version: 1,
555
+ text: "x",
556
+ },
557
+ });
558
+ transport.sent.length = 0;
559
+ });
560
+
561
+ afterEach(() => {
562
+ client.close();
563
+ const shutdown = transport.sent.find(
564
+ (message) => isRequest(message) && message.method === "shutdown",
565
+ );
566
+ if (shutdown && isRequest(shutdown)) {
567
+ transport.emit({ jsonrpc: "2.0", id: shutdown.id, result: null });
568
+ }
569
+ vi.useRealTimers();
570
+ vi.restoreAllMocks();
571
+ });
572
+
573
+ it("coalesces edits and sends the latest document before the completion", async () => {
574
+ for (const text of ["p", "pr", "pri", "prin", "print"]) {
575
+ await change(text);
576
+ }
577
+ expect(notifications(transport, "textDocument/didChange")).toEqual([]);
578
+ const response = client.getCompletion(params);
579
+ await tick();
580
+ expect(transport.sent).toEqual([
581
+ {
582
+ jsonrpc: "2.0",
583
+ method: "textDocument/didChange",
584
+ params: {
585
+ textDocument: { uri: params.textDocument.uri, version: 6 },
586
+ contentChanges: [{ text: "print" }],
587
+ },
588
+ },
589
+ {
590
+ jsonrpc: "2.0",
591
+ id: expect.any(Number),
592
+ method: "textDocument/inlineCompletion",
593
+ params: {
594
+ ...params,
595
+ textDocument: { uri: params.textDocument.uri, version: 6 },
596
+ },
597
+ },
598
+ ]);
599
+ transport.emit({
600
+ jsonrpc: "2.0",
601
+ id: requests()[0].id,
602
+ result: suggestion,
603
+ });
604
+ await expect(response).resolves.toEqual(suggestion);
605
+ expect(store.get(githubCopilotLoadingVersion)).toBeNull();
606
+ expect(await client.flushDocumentChanges()).toBe("print");
607
+ expect(notifications(transport, "textDocument/didChange")).toHaveLength(1);
608
+ });
609
+
610
+ it.each(["x", ""])(
611
+ "does not upload an unchanged focused cell %j again",
612
+ async (text) => {
613
+ const assemble = vi.spyOn(notebookCodes, "getCodes");
614
+ await change(text);
615
+ expect(await client.flushDocumentChanges(text)).toBe(text);
616
+ expect(await client.flushDocumentChanges(text)).toBe(text);
617
+ expect(await client.flushDocumentChanges(text)).toBe(text);
618
+ expect(assemble).toHaveBeenCalledTimes(3);
619
+ expect(notifications(transport, "textDocument/didChange")).toEqual([
620
+ {
621
+ jsonrpc: "2.0",
622
+ method: "textDocument/didChange",
623
+ params: {
624
+ textDocument: { uri: params.textDocument.uri, version: 2 },
625
+ contentChanges: [{ text }],
626
+ },
627
+ },
628
+ ]);
629
+
630
+ await change("next");
631
+ await client.flushDocumentChanges("next");
632
+ expect(
633
+ notifications(transport, "textDocument/didChange")[1].params,
634
+ ).toEqual({
635
+ textDocument: { uri: params.textDocument.uri, version: 3 },
636
+ contentChanges: [{ text: "next" }],
637
+ });
638
+ },
639
+ );
640
+
641
+ it("does not upload a document that already matches didOpen", async () => {
642
+ expect(await client.flushDocumentChanges("x")).toBe("x");
643
+ expect(notifications(transport, "textDocument/didChange")).toEqual([]);
644
+ });
645
+
646
+ it("compares the assembled notebook and uploads changes in another cell", async () => {
647
+ let otherCell = "import math";
648
+ const assemble = vi
649
+ .spyOn(notebookCodes, "getCodes")
650
+ .mockImplementation((code) => `${otherCell}\n${code}`);
651
+ await change("x");
652
+ expect(await client.flushDocumentChanges("x")).toBe("import math\nx");
653
+ expect(await client.flushDocumentChanges("x")).toBe("import math\nx");
654
+ otherCell = "import random";
655
+ expect(await client.flushDocumentChanges("x")).toBe("import random\nx");
656
+ expect(await client.flushDocumentChanges("x")).toBe("import random\nx");
657
+ expect(assemble).toHaveBeenCalledTimes(4);
658
+ expect(notifications(transport, "textDocument/didChange")).toEqual([
659
+ {
660
+ jsonrpc: "2.0",
661
+ method: "textDocument/didChange",
662
+ params: {
663
+ textDocument: { uri: params.textDocument.uri, version: 2 },
664
+ contentChanges: [{ text: "import math\nx" }],
665
+ },
666
+ },
667
+ {
668
+ jsonrpc: "2.0",
669
+ method: "textDocument/didChange",
670
+ params: {
671
+ textDocument: { uri: params.textDocument.uri, version: 3 },
672
+ contentChanges: [{ text: "import random\nx" }],
673
+ },
674
+ },
675
+ ]);
676
+ });
677
+
678
+ it("assembles once with the focused cell last when another cell has a pending edit", async () => {
679
+ const assemble = vi
680
+ .spyOn(notebookCodes, "getCodes")
681
+ .mockImplementation((code) => `other\n${code}`);
682
+ await change("other");
683
+ expect(await client.flushDocumentChanges("x")).toBe("other\nx");
684
+ expect(assemble).toHaveBeenCalledExactlyOnceWith("x");
685
+ expect(notifications(transport, "textDocument/didChange")).toEqual([
686
+ {
687
+ jsonrpc: "2.0",
688
+ method: "textDocument/didChange",
689
+ params: {
690
+ textDocument: { uri: params.textDocument.uri, version: 3 },
691
+ contentChanges: [{ text: "other\nx" }],
692
+ },
693
+ },
694
+ ]);
695
+ });
696
+
697
+ it("retries a failed upload even when its text matches the last attempted document", async () => {
698
+ const assemble = vi.spyOn(notebookCodes, "getCodes");
699
+ transport.send.mockImplementationOnce(() => {
700
+ throw new Error("socket closed");
701
+ });
702
+ await expect(client.flushDocumentChanges("latest")).rejects.toThrow(
703
+ "socket closed",
704
+ );
705
+ expect(await client.flushDocumentChanges("latest")).toBe("latest");
706
+ expect(await client.flushDocumentChanges("latest")).toBe("latest");
707
+ expect(assemble).toHaveBeenCalledTimes(3);
708
+ expect(notifications(transport, "textDocument/didChange")).toEqual([
709
+ {
710
+ jsonrpc: "2.0",
711
+ method: "textDocument/didChange",
712
+ params: {
713
+ textDocument: { uri: params.textDocument.uri, version: 2 },
714
+ contentChanges: [{ text: "latest" }],
715
+ },
716
+ },
717
+ ]);
718
+ });
719
+
720
+ it("cancels a rapid request instead of giving the next caller its promise", async () => {
721
+ const first = client.getCompletion(params);
722
+ await tick();
723
+ const second = client.getCompletion({
724
+ ...params,
725
+ position: { line: 0, character: 0 },
726
+ });
727
+ await tick();
728
+ expect(requests()).toHaveLength(2);
729
+ expect(notifications(transport, "$/cancelRequest")).toEqual([
730
+ {
731
+ jsonrpc: "2.0",
732
+ method: "$/cancelRequest",
733
+ params: { id: requests()[0].id },
734
+ },
735
+ ]);
736
+ await expect(first).resolves.toBeNull();
737
+ // Both requests use the same version; old cleanup must not hide the new spinner.
738
+ expect(store.get(githubCopilotLoadingVersion)).toBe(1);
739
+ transport.emit({
740
+ jsonrpc: "2.0",
741
+ id: requests()[1].id,
742
+ result: suggestion,
743
+ });
744
+ await expect(second).resolves.toEqual(suggestion);
745
+ transport.emit({
746
+ jsonrpc: "2.0",
747
+ id: requests()[0].id,
748
+ result: [{ insertText: "stale" }],
749
+ });
750
+ expect(store.get(githubCopilotLoadingVersion)).toBeNull();
751
+ });
752
+
753
+ it("suppresses a request superseded before it reaches the wire", async () => {
754
+ const first = client.getCompletion(params);
755
+ const second = client.getCompletion(params);
756
+ await tick();
757
+ expect(requests()).toHaveLength(1);
758
+ await expect(first).resolves.toBeNull();
759
+ transport.emit({
760
+ jsonrpc: "2.0",
761
+ id: requests()[0].id,
762
+ result: suggestion,
763
+ });
764
+ await expect(second).resolves.toEqual(suggestion);
765
+ });
766
+
767
+ it("aborts on an edit before any replacement completion is requested", async () => {
768
+ const response = client.getCompletion(params);
769
+ await tick();
770
+ await change("changed");
771
+ await expect(response).resolves.toBeNull();
772
+ expect(notifications(transport, "$/cancelRequest")).toHaveLength(1);
773
+ expect(notifications(transport, "textDocument/didChange")).toEqual([]);
774
+ expect(store.get(githubCopilotLoadingVersion)).toBeNull();
775
+ });
776
+
777
+ it.each([false, true])(
778
+ "handles external cancellation (already sent: %s)",
779
+ async (sent) => {
780
+ const controller = new AbortController();
781
+ if (!sent) {
782
+ controller.abort();
783
+ }
784
+ const response = client.getCompletion(params, controller.signal);
785
+ if (sent) {
786
+ await tick();
787
+ controller.abort();
788
+ }
789
+ await expect(response).resolves.toBeNull();
790
+ expect(requests()).toHaveLength(sent ? 1 : 0);
791
+ expect(notifications(transport, "$/cancelRequest")).toHaveLength(
792
+ sent ? 1 : 0,
793
+ );
794
+ expect(store.get(githubCopilotLoadingVersion)).toBeNull();
795
+ },
796
+ );
797
+
798
+ it.each([null, [], { items: [] }])(
799
+ "handles empty response %j",
800
+ async (result) => {
801
+ const response = client.getCompletion(params);
802
+ await tick();
803
+ transport.emit({ jsonrpc: "2.0", id: requests()[0].id, result });
804
+ await expect(response).resolves.toEqual(result);
805
+ expect(store.get(githubCopilotLoadingVersion)).toBeNull();
806
+ },
807
+ );
808
+
809
+ it("fails quietly on a server error and allows a subsequent request", async () => {
810
+ const response = client.getCompletion(params);
811
+ await tick();
812
+ transport.emit({
813
+ jsonrpc: "2.0",
814
+ id: requests()[0].id,
815
+ error: { code: -32603, message: "unavailable" },
816
+ });
817
+ await expect(response).resolves.toBeNull();
818
+ const retry = client.getCompletion(params);
819
+ await tick();
820
+ transport.emit({
821
+ jsonrpc: "2.0",
822
+ id: requests()[1].id,
823
+ result: suggestion,
824
+ });
825
+ await expect(retry).resolves.toEqual(suggestion);
826
+ });
827
+
828
+ it("cancels a timed-out request and clears its loading state", async () => {
829
+ const response = client.getCompletion(params);
830
+ await vi.advanceTimersByTimeAsync(10_000);
831
+ await expect(response).resolves.toBeNull();
832
+ expect(notifications(transport, "$/cancelRequest")).toHaveLength(1);
833
+ expect(store.get(githubCopilotLoadingVersion)).toBeNull();
834
+ });
835
+
836
+ it("does not send requests while disabled or display a response after disabling", async () => {
837
+ copilotState.isEnabled = false;
838
+ await expect(client.getCompletion(params)).resolves.toBeNull();
839
+ expect(requests()).toEqual([]);
840
+ copilotState.isEnabled = true;
841
+ const response = client.getCompletion(params);
842
+ await tick();
843
+ copilotState.isEnabled = false;
844
+ transport.emit({
845
+ jsonrpc: "2.0",
846
+ id: requests()[0].id,
847
+ result: suggestion,
848
+ });
849
+ await expect(response).resolves.toBeNull();
850
+ });
851
+
852
+ it("preserves pending edits after a failed document upload and retries them", async () => {
853
+ await change("latest");
854
+ transport.send.mockImplementationOnce(() => {
855
+ throw new Error("socket closed");
856
+ });
857
+ await expect(client.getCompletion(params)).resolves.toBeNull();
858
+ expect(requests()).toEqual([]);
859
+ const response = client.getCompletion(params);
860
+ await tick();
861
+ expect(notifications(transport, "textDocument/didChange")).toEqual([
862
+ {
863
+ jsonrpc: "2.0",
864
+ method: "textDocument/didChange",
865
+ params: {
866
+ textDocument: { uri: params.textDocument.uri, version: 2 },
867
+ contentChanges: [{ text: "latest" }],
868
+ },
869
+ },
870
+ ]);
871
+ transport.emit({
872
+ jsonrpc: "2.0",
873
+ id: requests()[0].id,
874
+ result: suggestion,
875
+ });
876
+ await expect(response).resolves.toEqual(suggestion);
877
+ });
878
+
879
+ it.each(["success", "failure"])(
880
+ "cancels a request during a stalled reconnect before handshake %s",
881
+ async (outcome) => {
882
+ transport.send.mockImplementationOnce((message) => {
883
+ transport.sent.push(message);
884
+ });
885
+ const reconnect = client.reInitialize().then(
886
+ () => "ready",
887
+ (error: unknown) => error,
888
+ );
889
+ const initialize = transport.sent.find(
890
+ (message) => isRequest(message) && message.method === "initialize",
891
+ );
892
+ if (!initialize || !isRequest(initialize)) {
893
+ throw new Error("Expected initialize request");
894
+ }
895
+ const controller = new AbortController();
896
+ const remove = vi.spyOn(controller.signal, "removeEventListener");
897
+ const settled = vi.fn();
898
+ const response = client
899
+ .getCompletion(params, controller.signal)
900
+ .then(settled);
901
+ await tick();
902
+ expect(settled).not.toHaveBeenCalled();
903
+ expect(requests()).toEqual([]);
904
+ expect(store.get(githubCopilotLoadingVersion)).toBe(1);
905
+ controller.abort();
906
+ await tick();
907
+ // Assert before answering initialize so the handshake cannot hide a stuck abort.
908
+ expect(settled).toHaveBeenCalledExactlyOnceWith(null);
909
+ expect(remove).toHaveBeenCalledExactlyOnceWith(
910
+ "abort",
911
+ expect.any(Function),
912
+ );
913
+ expect(store.get(githubCopilotLoadingVersion)).toBeNull();
914
+ expect(notifications(transport, "$/cancelRequest")).toEqual([]);
915
+ transport.emit(
916
+ outcome === "success"
917
+ ? {
918
+ jsonrpc: "2.0",
919
+ id: initialize.id,
920
+ result: { capabilities: {} },
921
+ }
922
+ : {
923
+ jsonrpc: "2.0",
924
+ id: initialize.id,
925
+ error: { code: -32603, message: "handshake failed" },
926
+ },
927
+ );
928
+ const handshakeError = expect.objectContaining({
929
+ message: "handshake failed",
930
+ });
931
+ await expect(reconnect).resolves.toEqual(
932
+ outcome === "success" ? "ready" : handshakeError,
933
+ );
934
+ expect(requests()).toEqual([]);
935
+ let retryResult: unknown;
936
+ if (outcome === "success") {
937
+ const retry = client.getCompletion(params);
938
+ await tick();
939
+ transport.emit({
940
+ jsonrpc: "2.0",
941
+ id: requests()[0].id,
942
+ result: suggestion,
943
+ });
944
+ retryResult = await retry;
945
+ }
946
+ expect(requests()).toHaveLength(outcome === "success" ? 1 : 0);
947
+ expect(retryResult).toEqual(
948
+ outcome === "success" ? suggestion : undefined,
949
+ );
950
+ await response;
951
+ expect(settled).toHaveBeenCalledTimes(1);
952
+ },
953
+ );
954
+
955
+ it("replays the most recent unsent edit on reconnect", async () => {
956
+ await change("first");
957
+ await change("latest");
958
+ await client.reInitialize();
959
+ expect(notifications(transport, "textDocument/didOpen")).toEqual([
960
+ {
961
+ jsonrpc: "2.0",
962
+ method: "textDocument/didOpen",
963
+ params: {
964
+ textDocument: {
965
+ uri: params.textDocument.uri,
966
+ languageId: "python",
967
+ version: 3,
968
+ text: "latest",
969
+ },
970
+ },
971
+ },
972
+ ]);
973
+ expect(notifications(transport, "textDocument/didChange")).toEqual([]);
974
+ });
975
+
976
+ it("cancels requests and drops pending changes when the final editor closes", async () => {
977
+ const response = client.getCompletion(params);
978
+ await tick();
979
+ await client.textDocumentDidClose({ textDocument: params.textDocument });
980
+ await expect(response).resolves.toBeNull();
981
+ await change("unsent");
982
+ await client.textDocumentDidClose({ textDocument: params.textDocument });
983
+ await client.flushDocumentChanges();
984
+ expect(notifications(transport, "textDocument/didChange")).toEqual([]);
985
+ });
986
+ it("does not request completions for a closed document", async () => {
987
+ await client.textDocumentDidClose({ textDocument: params.textDocument });
988
+ await expect(client.getCompletion(params)).resolves.toBeNull();
989
+ expect(requests()).toEqual([]);
990
+ expect(store.get(githubCopilotLoadingVersion)).toBeNull();
991
+ });
992
+
993
+ it("keeps a newer edit when an earlier upload fails", async () => {
994
+ await change("old");
995
+ transport.send.mockImplementationOnce(() => {
996
+ void change("new");
997
+ throw new Error("send failed");
998
+ });
999
+ await expect(client.flushDocumentChanges("old")).rejects.toThrow(
1000
+ "send failed",
1001
+ );
1002
+ expect(await client.flushDocumentChanges("new")).toBe("new");
1003
+ expect(notifications(transport, "textDocument/didChange")).toEqual([
1004
+ {
1005
+ jsonrpc: "2.0",
1006
+ method: "textDocument/didChange",
1007
+ params: {
1008
+ textDocument: { uri: params.textDocument.uri, version: 3 },
1009
+ contentChanges: [{ text: "new" }],
1010
+ },
1011
+ },
1012
+ ]);
1013
+ });
1014
+ });