@marimo-team/frontend 0.25.3-dev80 → 0.25.3-dev81

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 (88) hide show
  1. package/dist/assets/{CellStatus-cD3S0wEA.js → CellStatus-DXroYLOP.js} +1 -1
  2. package/dist/assets/{ErrorBoundary-DJhsFCsX.js → ErrorBoundary-DaQ9THH_.js} +2 -2
  3. package/dist/assets/{JsonOutput-Pm6OCP3J.js → JsonOutput-VxzHyItt.js} +1 -1
  4. package/dist/assets/{MarimoErrorOutput-DlEjLCZ-.js → MarimoErrorOutput-D-hLaDA-.js} +1 -1
  5. package/dist/assets/{RenderHTML-CfQ6i3QV.js → RenderHTML-DjOEZL25.js} +1 -1
  6. package/dist/assets/{RunButton-DvuvkgrG.js → RunButton-CuhNTo5_.js} +1 -1
  7. package/dist/assets/{add-cell-with-ai-By51zSlC.js → add-cell-with-ai-BbVntTh7.js} +1 -1
  8. package/dist/assets/{add-connection-dialog-D7EYSraq.js → add-connection-dialog-Qf63aKV6.js} +1 -1
  9. package/dist/assets/{agent-panel-aCWa3Yai.js → agent-panel-DMOfF3qF.js} +1 -1
  10. package/dist/assets/{ai-model-dropdown-bwTRyFn4.js → ai-model-dropdown-B15l0R62.js} +2 -2
  11. package/dist/assets/{app-config-button-DGhsM0xw.js → app-config-button-BIMB8qjD.js} +1 -1
  12. package/dist/assets/{blob-e6VlOmQP.js → blob-De9GR2s6.js} +1 -1
  13. package/dist/assets/{cell-editor-M8dEnDzk.js → cell-editor-DFNLDjfX.js} +3 -3
  14. package/dist/assets/{cell-link-DFqfW-Ym.js → cell-link-DR3W2HC7.js} +1 -1
  15. package/dist/assets/{cells-T9_Y4Jlv.js → cells-styV7IX8.js} +6 -6
  16. package/dist/assets/{chat-display-DXn2Yrjr.js → chat-display-BzdYdN39.js} +1 -1
  17. package/dist/assets/{chat-panel-D3iUyZId.js → chat-panel-De3YzkSF.js} +1 -1
  18. package/dist/assets/{chat-ui-YuJtv3UJ.js → chat-ui-NTNapdtP.js} +1 -1
  19. package/dist/assets/{code-visibility-CV3S4xuf.js → code-visibility-csc3tgA0.js} +1 -1
  20. package/dist/assets/{column-preview-C6uXRJry.js → column-preview-DkH-oD8f.js} +1 -1
  21. package/dist/assets/{command-palette-CGNUQdgW.js → command-palette-OOQHBTnr.js} +1 -1
  22. package/dist/assets/{common-DeeLgjWQ.js → common-D7VAS6rd.js} +1 -1
  23. package/dist/assets/{components-DyAF3_Nf.js → components-CNmvC781.js} +1 -1
  24. package/dist/assets/config-BQT0uYUo.js +1 -0
  25. package/dist/assets/{config-CjQ4WJnJ.js → config-BbRbT9Ww.js} +1 -1
  26. package/dist/assets/{datasource-gqFgG9Qd.js → datasource-C3yfeDa2.js} +1 -1
  27. package/dist/assets/{dependency-graph-panel-B3T9kSTs.js → dependency-graph-panel-CjoA6L0w.js} +1 -1
  28. package/dist/assets/{documentation-panel-BBpGbT-f.js → documentation-panel-DHWFOiG1.js} +1 -1
  29. package/dist/assets/{download-C8Gw-D0Y.js → download-BrCpRZKN.js} +1 -1
  30. package/dist/assets/{edit-page-CBftLPze.js → edit-page-DafDcIAI.js} +4 -4
  31. package/dist/assets/{error-entries-uyqOG5tg.js → error-entries-BkEAmmnD.js} +1 -1
  32. package/dist/assets/{error-panel-Cli5zPWi.js → error-panel-BhTfPA_i.js} +1 -1
  33. package/dist/assets/feedback-button-BAcClv7c.js +1 -0
  34. package/dist/assets/{feedback-button-CvrwBGr7.js → feedback-button-D9InK87G.js} +1 -1
  35. package/dist/assets/{file-explorer-panel-BSU5ultY.js → file-explorer-panel-BSd_ZbUC.js} +1 -1
  36. package/dist/assets/{file-name-input-CDrRlIiX.js → file-name-input-RHURglL_.js} +1 -1
  37. package/dist/assets/{floating-outline-GQD9km0W.js → floating-outline-Cm49rYYe.js} +1 -1
  38. package/dist/assets/{gallery-page-B-5H1fEt.js → gallery-page-DdMdOtdv.js} +1 -1
  39. package/dist/assets/{glide-data-editor-B1kpoetH.js → glide-data-editor-eiSlEUyR.js} +1 -1
  40. package/dist/assets/{home-page-3qsPTb4v.js → home-page-DwWvBB0R.js} +1 -1
  41. package/dist/assets/{hooks-BrFYiTJS.js → hooks-C4GjmtQ-.js} +1 -1
  42. package/dist/assets/{index-D0IWODMU.js → index-jR_KRDMC.js} +7 -7
  43. package/dist/assets/{layout-CGC8ajpe.js → layout-Bq7wRzdR.js} +3 -3
  44. package/dist/assets/{logs-panel-CdUc5vtA.js → logs-panel-CAAk72Gb.js} +1 -1
  45. package/dist/assets/{markdown-renderer-pKc_bFac.js → markdown-renderer-CAyCgogC.js} +1 -1
  46. package/dist/assets/{name-cell-input-csH3Cy54.js → name-cell-input-DA8hU9XT.js} +1 -1
  47. package/dist/assets/{outline-panel-DhsHYTuo.js → outline-panel-Cc5oQ4LL.js} +1 -1
  48. package/dist/assets/{packages-panel-BtkXVz3L.js → packages-panel-DTfKez6I.js} +1 -1
  49. package/dist/assets/{panel-accordion-state-Bco6nsUI.js → panel-accordion-state-BZHex38W.js} +1 -1
  50. package/dist/assets/panels-Cnu2Flfw.js +105 -0
  51. package/dist/assets/{process-output-B2-bT4cq.js → process-output-CrDc_O1D.js} +1 -1
  52. package/dist/assets/{readonly-code-display-Dsp6vWPh.js → readonly-code-display-BR-_jUj8.js} +1 -1
  53. package/dist/assets/{readonly-python-code-B81AjzXM.js → readonly-python-code-D7jcy9ds.js} +1 -1
  54. package/dist/assets/{reveal-component-Cxo3hLGA.js → reveal-component-BWu6kkxZ.js} +1 -1
  55. package/dist/assets/{run-page-CLP1s8uX.js → run-page-BJq9MULx.js} +1 -1
  56. package/dist/assets/{runtime-details-state-CznIe40L.js → runtime-details-state-hb7A1jgR.js} +1 -1
  57. package/dist/assets/{scratchpad-panel-yHIW63VQ.js → scratchpad-panel-OHQ7Z0RY.js} +1 -1
  58. package/dist/assets/{session-panel-C7-wmZhc.js → session-panel-DmDjaPsN.js} +1 -1
  59. package/dist/assets/{snippets-panel-BARpOPyS.js → snippets-panel-o9KL5jMi.js} +1 -1
  60. package/dist/assets/state-BkAqJoW7.js +3 -0
  61. package/dist/assets/{state-Bye6rCo4.js → state-Os7LSUUZ.js} +1 -1
  62. package/dist/assets/{terminal-Bhcn_y1N.js → terminal-BI85khrS.js} +1 -1
  63. package/dist/assets/{tracing-DDohxo24.js → tracing-AsNGuDsh.js} +1 -1
  64. package/dist/assets/{tracing-panel-DYhGDAo5.js → tracing-panel-CBcmea6L.js} +2 -2
  65. package/dist/assets/{tree-actions-Cb-nUK1F.js → tree-actions-Cx5EDpA6.js} +1 -1
  66. package/dist/assets/{useAddCell-Bielffe0.js → useAddCell-uJJYtgwa.js} +1 -1
  67. package/dist/assets/{useBoolean-CMgj9hnq.js → useBoolean-8wlrTeus.js} +1 -1
  68. package/dist/assets/{useCellActionButton-DAcQhse_.js → useCellActionButton-CmQ-OcPa.js} +1 -1
  69. package/dist/assets/{useDeleteCell-BV4VemCv.js → useDeleteCell-CXgOCDqY.js} +1 -1
  70. package/dist/assets/{useDependencyPanelTab-PtXIVh53.js → useDependencyPanelTab-Ch6gm7uM.js} +1 -1
  71. package/dist/assets/{useInstallPackage-C7neMPwh.js → useInstallPackage-CYQoMdMQ.js} +1 -1
  72. package/dist/assets/{useNotebookActions-DZBB98fP.js → useNotebookActions-CqXa3U6u.js} +1 -1
  73. package/dist/assets/{useRunCells-BFeZMi7f.js → useRunCells-DAfDmOMC.js} +1 -1
  74. package/dist/assets/{useSplitCell-DllkNFke.js → useSplitCell-NLULgcq7.js} +1 -1
  75. package/dist/assets/{vega-component-D7JFthDD.js → vega-component-BKJU3uaB.js} +1 -1
  76. package/dist/index.html +24 -24
  77. package/package.json +1 -1
  78. package/src/core/host/__tests__/notebook-client.test.ts +368 -350
  79. package/src/core/host/notebook-client.ts +129 -186
  80. package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +133 -59
  81. package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +152 -250
  82. package/src/core/websocket/transports/__tests__/notebook.test.ts +502 -0
  83. package/src/core/websocket/transports/notebook.ts +213 -0
  84. package/src/core/websocket/useNotebookClient.ts +28 -17
  85. package/dist/assets/config-DzdxZb17.js +0 -1
  86. package/dist/assets/feedback-button-DknbRCN_.js +0 -1
  87. package/dist/assets/panels-D1i80TGn.js +0 -107
  88. package/dist/assets/state-BRYi026f.js +0 -3
@@ -1,42 +1,42 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  // @vitest-environment jsdom
3
3
 
4
- import { describe, expect, it, vi } from "vitest";
4
+ import { type NotebookTransport, StreamStopped } from "@marimo-team/client";
5
5
  import { createStore } from "jotai";
6
- import { ConnectionState } from "../../websocket/types";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
7
  import { Deferred } from "@/utils/Deferred";
8
- import { jsonParseWithSpecialChar } from "@/utils/json/json-parser";
9
- import { classifyCloseEvent } from "../../websocket/connection-state";
8
+ import { ConnectionState } from "../../websocket/types";
9
+ import type { ConnectionStatus } from "../connection";
10
10
  import { createNotebookClient } from "../notebook-client";
11
11
  import { fixtureEvents, fixtureMessages } from "./fixtures";
12
12
 
13
- function notebookMessage(id: string) {
14
- const notebook = {
15
- id,
16
- project_id: "p1",
17
- path: null,
18
- name: id,
19
- operations: [],
20
- updated_at: null,
21
- };
22
- return JSON.stringify({ event: "notebook", data: { notebook } });
13
+ /** One connection the fake transport opened. */
14
+ interface Attempt {
15
+ readonly cursor: string | null;
16
+ readonly signal: AbortSignal;
17
+ open(): void;
18
+ receive(message: unknown): void;
19
+ /** End the connection so the stream reconnects. */
20
+ drop(): void;
21
+ /** End the stream, with the status the editor shows if the transport has one. */
22
+ stop(status?: ConnectionStatus): void;
23
23
  }
24
24
 
25
- function setup(url: () => string = () => "ws://localhost/ws") {
26
- const events = new EventTarget();
27
- const healthy = new Deferred<void>();
28
- let provider = url;
29
- const urls: URL[] = [];
30
- const transport = {
31
- readyState: WebSocket.CLOSED,
32
- reconnect: vi.fn(() => {
33
- urls.push(new URL(provider()));
34
- }),
35
- close: vi.fn(() => events.dispatchEvent(new CloseEvent("close"))),
36
- send: vi.fn(),
37
- addEventListener: events.addEventListener.bind(events),
38
- removeEventListener: events.removeEventListener.bind(events),
39
- };
25
+ function setup() {
26
+ const attempts: Attempt[] = [];
27
+ const transport: NotebookTransport = ({ cursor, signal }, handlers) =>
28
+ new Promise((resolve, reject) => {
29
+ attempts.push({
30
+ cursor,
31
+ signal,
32
+ open: handlers.open,
33
+ receive: handlers.message,
34
+ drop: resolve,
35
+ stop: (status) =>
36
+ reject(new StreamStopped("stopped", { cause: status })),
37
+ });
38
+ signal.addEventListener("abort", () => resolve(), { once: true });
39
+ });
40
40
  const onNotification = vi.fn();
41
41
  const checkHealth = vi.fn(async () => true);
42
42
  const store = createStore();
@@ -44,51 +44,64 @@ function setup(url: () => string = () => "ws://localhost/ws") {
44
44
  const client = controller.client;
45
45
  const connect = (signal: AbortSignal) =>
46
46
  controller.connect({
47
- createTransport: (next) => {
48
- provider = next;
49
- return transport;
50
- },
51
- url,
47
+ transport,
52
48
  signal,
53
- waitUntilReady: () => healthy.promise,
54
49
  checkHealth,
55
50
  onNotification: (notification) => onNotification({ data: notification }),
56
- decodeJson: jsonParseWithSpecialChar,
57
- classifyClose: classifyCloseEvent,
58
51
  });
52
+ let next = 0;
53
+ /** Wait for the next connection, and open it. */
54
+ const opened = async () => {
55
+ await vi.waitFor(() => expect(attempts.length).toBeGreaterThan(next));
56
+ const attempt = attempts[next++];
57
+ attempt.open();
58
+ return attempt;
59
+ };
59
60
  return {
60
- events,
61
- healthy,
62
- transport,
61
+ attempts,
63
62
  client,
64
63
  connect,
65
64
  onNotification,
66
65
  store,
67
66
  checkHealth,
68
- urls,
69
- receive: (message: unknown) =>
70
- events.dispatchEvent(
71
- new MessageEvent("message", { data: JSON.stringify(message) }),
72
- ),
67
+ opened,
73
68
  };
74
69
  }
75
70
 
71
+ const lifetimes: AbortController[] = [];
72
+ function lifetime() {
73
+ const controller = new AbortController();
74
+ lifetimes.push(controller);
75
+ return controller;
76
+ }
77
+
78
+ afterEach(() => {
79
+ lifetimes.splice(0).forEach((controller) => controller.abort());
80
+ vi.useRealTimers();
81
+ });
82
+
83
+ /** Wait out the stream's backoff before it reconnects. */
84
+ const backoff = () => vi.advanceTimersByTimeAsync(2000);
85
+
86
+ const kernelReady = {
87
+ event: "marimo",
88
+ data: { marimo: { op: "kernel-ready", resumed: false } },
89
+ };
90
+
91
+ const notebook = {
92
+ id: "nb/with space",
93
+ project_id: "p1",
94
+ path: null,
95
+ name: "Notebook",
96
+ operations: ["notebook.edit"],
97
+ updated_at: null,
98
+ };
99
+
76
100
  describe("NotebookClient lifetime", () => {
77
101
  it("exposes identity and capabilities only after the notebook is ready", async () => {
78
- const { connect, healthy, events, receive, client, store } = setup();
79
- const lifetime = new AbortController();
80
- connect(lifetime.signal);
81
- healthy.resolve();
82
- await healthy.promise;
83
- events.dispatchEvent(new Event("open"));
84
- const notebook = {
85
- id: "nb/with space",
86
- project_id: "p1",
87
- path: null,
88
- name: "Notebook",
89
- operations: ["notebook.edit"],
90
- updated_at: null,
91
- };
102
+ const { connect, opened, client, store } = setup();
103
+ connect(lifetime().signal);
104
+ const { receive } = await opened();
92
105
  receive({ event: "notebook", data: { notebook } });
93
106
  expect(store.get(client.notebookAtom)).toBeNull();
94
107
  receive({ event: "ready", id: "1", data: {} });
@@ -101,396 +114,401 @@ describe("NotebookClient lifetime", () => {
101
114
  expect(store.get(client.notebookAtom)?.operations).toEqual([]);
102
115
  receive({ event: "notebook.removed", id: "3", data: { id: notebook.id } });
103
116
  expect(store.get(client.notebookAtom)).toBeNull();
104
- lifetime.abort();
105
117
  });
106
118
 
107
119
  it("rebuilds the replica after a reconnect reset selects a new notebook", async () => {
108
- const notebookId = "n2";
109
- const { connect, events, healthy, urls, receive, client, store } = setup(
110
- () => "ws://localhost/ws?session_id=visitor",
111
- );
112
- const lifetime = new AbortController();
113
- connect(lifetime.signal);
114
- healthy.resolve();
115
- await healthy.promise;
116
- events.dispatchEvent(new Event("open"));
117
- fixtureMessages("notebook-events.sse").forEach(receive);
120
+ vi.useFakeTimers();
121
+ const { connect, opened, client, store } = setup();
122
+ connect(lifetime().signal);
123
+ const first = await opened();
124
+ fixtureMessages("notebook-events.sse").forEach(first.receive);
118
125
  expect(store.get(client.documentAtom)?.revision.notebook_id).toBe("n1");
119
126
 
120
- events.dispatchEvent(new CloseEvent("close"));
121
- expect(urls.at(-1)?.searchParams.get("cursor")).toBe("97");
127
+ first.drop();
128
+ await backoff();
122
129
  expect(store.get(client.documentAtom)?.revision.notebook_id).toBe("n1");
123
130
  expect(store.get(client.notebookAtom)).toBeNull();
124
- events.dispatchEvent(new Event("open"));
125
- receive({ event: "reset", data: {} });
131
+ const second = await opened();
132
+ expect(second.cursor).toBe("97");
133
+ second.receive({ event: "reset", data: {} });
126
134
  expect(store.get(client.documentAtom)).toBeNull();
127
135
  for (const event of fixtureMessages("notebook-events.sse")) {
128
- receive(
129
- JSON.parse(
130
- JSON.stringify(event).replaceAll('"n1"', JSON.stringify(notebookId)),
131
- ),
136
+ second.receive(
137
+ JSON.parse(JSON.stringify(event).replaceAll('"n1"', '"n2"')),
132
138
  );
133
139
  }
134
- expect(store.get(client.documentAtom)?.revision.notebook_id).toBe(
135
- notebookId,
136
- );
140
+ expect(store.get(client.documentAtom)?.revision.notebook_id).toBe("n2");
137
141
  expect(store.get(client.connectionAtom).state).toBe(ConnectionState.OPEN);
138
- expect(store.get(client.notebookAtom)?.id).toBe(notebookId);
139
- lifetime.abort();
142
+ expect(store.get(client.notebookAtom)?.id).toBe("n2");
140
143
  });
141
144
 
142
- it.each(["{broken", JSON.stringify({ event: "ready", data: {} })])(
143
- "stops consuming a malformed host stream: %s",
144
- (data) => {
145
- const { connect, events, client, store, transport, onNotification } =
146
- setup();
147
- const lifetime = new AbortController();
148
- connect(lifetime.signal);
149
- events.dispatchEvent(new Event("open"));
150
- events.dispatchEvent(new MessageEvent("message", { data }));
151
- events.dispatchEvent(
152
- new MessageEvent("message", {
153
- data: JSON.stringify({
154
- event: "marimo",
155
- data: { marimo: { op: "reconnected" } },
156
- }),
157
- }),
158
- );
159
- expect(store.get(client.connectionAtom)).toEqual({
160
- state: ConnectionState.CLOSED,
161
- code: "KERNEL_DISCONNECTED",
162
- reason: "Failed to decode notebook message",
145
+ it.each([
146
+ ["a message that is not an event", "{broken"],
147
+ ["an event from another notebook", "identity"],
148
+ ])("stops consuming a stream on %s", async (_name, message) => {
149
+ const { connect, opened, client, store, onNotification } = setup();
150
+ connect(lifetime().signal);
151
+ const attempt = await opened();
152
+ if (message === "identity") {
153
+ attempt.receive({ event: "notebook", data: { notebook } });
154
+ attempt.receive({
155
+ event: "notebook",
156
+ data: { notebook: { ...notebook, id: "n2" } },
163
157
  });
164
- expect(transport.close).toHaveBeenCalledOnce();
165
- expect(onNotification).not.toHaveBeenCalled();
166
- lifetime.abort();
167
- },
168
- );
169
-
170
- it("resumes from the last cursor until reset or a new session", async () => {
171
- const session = { id: "first", token: "a" };
172
- const { connect, events, healthy, urls, receive } = setup(
173
- () =>
174
- `ws://localhost/ws?session_id=${session.id}&access_token=${session.token}&cursor=999`,
175
- );
176
- const lifetime = new AbortController();
177
- connect(lifetime.signal);
178
- healthy.resolve();
179
- await healthy.promise;
180
- expect(urls[0].searchParams.get("cursor")).toBeNull();
181
-
182
- const snapshot = fixtureMessages("notebook-events.sse");
183
- events.dispatchEvent(new Event("open"));
184
- for (const message of snapshot) {
185
- receive(message);
158
+ } else {
159
+ attempt.receive(message);
186
160
  }
187
- session.token = "b";
188
- events.dispatchEvent(new CloseEvent("close"));
189
- expect(urls[1].searchParams.get("cursor")).toBe("97");
190
- expect(urls[1].searchParams.get("access_token")).toBe("b");
191
-
192
- events.dispatchEvent(new Event("open"));
193
- receive({ event: "reset", data: {} });
194
- events.dispatchEvent(new CloseEvent("close"));
195
- expect(urls[2].searchParams.get("cursor")).toBeNull();
196
-
197
- events.dispatchEvent(new Event("open"));
198
- for (const message of snapshot) {
199
- receive(message);
200
- }
201
- session.id = "second";
202
- events.dispatchEvent(new CloseEvent("close"));
203
- expect(urls[3].searchParams.get("cursor")).toBeNull();
204
- expect(urls[3].searchParams.get("session_id")).toBe("second");
205
- lifetime.abort();
161
+ attempt.receive({
162
+ event: "marimo",
163
+ data: { marimo: { op: "reconnected" } },
164
+ });
165
+ await vi.waitFor(() =>
166
+ expect(store.get(client.connectionAtom).state).toBe(
167
+ ConnectionState.CLOSED,
168
+ ),
169
+ );
170
+ expect(store.get(client.connectionAtom)).toMatchInlineSnapshot(`
171
+ {
172
+ "code": "KERNEL_DISCONNECTED",
173
+ "reason": "Failed to decode notebook message",
174
+ "state": "CLOSED",
175
+ }
176
+ `);
177
+ expect(attempt.signal.aborted).toBe(true);
178
+ expect(onNotification).not.toHaveBeenCalled();
206
179
  });
207
180
 
208
181
  it("exposes the confirmed document until the lifetime ends", async () => {
209
- const { connect, events, client, store, healthy, receive } = setup();
210
- const lifetime = new AbortController();
211
- connect(lifetime.signal);
212
- healthy.resolve();
213
- await healthy.promise;
214
- events.dispatchEvent(new Event("open"));
182
+ vi.useFakeTimers();
183
+ const { connect, opened, client, store } = setup();
184
+ const current = lifetime();
185
+ connect(current.signal);
186
+ const attempt = await opened();
215
187
  const snapshot = fixtureMessages("notebook-events.sse");
216
- for (const message of snapshot.slice(0, -3)) {
217
- receive(message);
218
- }
188
+ snapshot.slice(0, -3).forEach(attempt.receive);
219
189
  expect(store.get(client.documentAtom)).toBeNull();
220
- for (const message of snapshot.slice(-3)) {
221
- receive(message);
222
- }
190
+ snapshot.slice(-3).forEach(attempt.receive);
223
191
  expect(store.get(client.documentAtom)?.revision.order).toEqual([
224
192
  "c1",
225
193
  "c3",
226
194
  "k7f2",
227
195
  ]);
228
- events.dispatchEvent(new CloseEvent("close"));
196
+ attempt.drop();
197
+ await backoff();
229
198
  expect(store.get(client.documentAtom)?.revision.order).toEqual([
230
199
  "c1",
231
200
  "c3",
232
201
  "k7f2",
233
202
  ]);
234
- lifetime.abort();
203
+ current.abort();
235
204
  expect(store.get(client.documentAtom)).toBeNull();
236
205
  });
237
206
 
238
- it("stops consuming a stream whose notebook changes", () => {
239
- const { connect, events, client, store, transport } = setup();
240
- const lifetime = new AbortController();
241
- connect(lifetime.signal);
242
- events.dispatchEvent(new Event("open"));
243
- for (const id of ["n1", "n2"]) {
244
- events.dispatchEvent(
245
- new MessageEvent("message", { data: notebookMessage(id) }),
246
- );
247
- }
248
- expect(store.get(client.connectionAtom)).toEqual({
249
- state: ConnectionState.CLOSED,
250
- code: "KERNEL_DISCONNECTED",
251
- reason: "Failed to decode notebook message",
252
- });
253
- expect(transport.close).toHaveBeenCalledOnce();
254
- lifetime.abort();
255
- });
207
+ it.each([
208
+ ["a malformed native envelope", { event: "marimo", id: "98", data: {} }],
209
+ ["null native event data", { event: "marimo", id: "98", data: null }],
210
+ ["an invalid protocol record", { event: "cell", id: "98", data: {} }],
211
+ ["an undecodable message", "{broken"],
212
+ ["a changed notebook", { event: "notebook", id: "98", data: { notebook } }],
213
+ ])(
214
+ "rebuilds from a fresh snapshot after rejecting %s",
215
+ async (_name, message) => {
216
+ const { connect, opened, client, store, onNotification } = setup();
217
+ connect(lifetime().signal);
218
+ const first = await opened();
219
+ const snapshot = fixtureMessages("notebook-events.sse");
220
+ snapshot.forEach(first.receive);
221
+ expect(store.get(client.documentAtom)).not.toBeNull();
222
+ onNotification.mockClear();
256
223
 
257
- it.each(["MARIMO_UNAUTHORIZED", "MARIMO_TRANSPORT_EXHAUSTED"])(
258
- "stops automatic recovery after %s but permits an explicit reconnect",
259
- async (reason) => {
260
- const {
261
- connect,
262
- client,
263
- store,
264
- events,
265
- transport,
266
- healthy,
267
- checkHealth,
268
- } = setup();
269
- const lifetime = new AbortController();
270
- connect(lifetime.signal);
271
- healthy.resolve();
272
- await healthy.promise;
273
- events.dispatchEvent(new CloseEvent("close", { reason }));
274
- events.dispatchEvent(new Event("error"));
275
- expect(store.get(client.connectionAtom).state).toBe(
276
- ConnectionState.CLOSED,
224
+ first.receive(message);
225
+ first.receive(kernelReady);
226
+ expect({
227
+ connection: store.get(client.connectionAtom),
228
+ document: store.get(client.documentAtom),
229
+ attachments: store.get(client.attachmentsAtom),
230
+ aborted: first.signal.aborted,
231
+ notifications: onNotification.mock.calls,
232
+ }).toEqual({
233
+ connection: {
234
+ state: ConnectionState.CLOSED,
235
+ code: "KERNEL_DISCONNECTED",
236
+ reason: "Failed to decode notebook message",
237
+ },
238
+ document: null,
239
+ attachments: [],
240
+ aborted: true,
241
+ notifications: [],
242
+ });
243
+
244
+ await client.reconnect();
245
+ const second = await opened();
246
+ expect(second.cursor).toBeNull();
247
+ snapshot.forEach(second.receive);
248
+ expect(store.get(client.documentAtom)?.revision.notebook_id).toBe("n1");
249
+ expect(store.get(client.connectionAtom)).toEqual({
250
+ state: ConnectionState.OPEN,
251
+ });
252
+ },
253
+ );
254
+
255
+ it.each([
256
+ [
257
+ "a final close",
258
+ {
259
+ state: ConnectionState.CLOSED,
260
+ code: "KERNEL_DISCONNECTED",
261
+ reason: "not authorized",
262
+ },
263
+ ],
264
+ ["the host giving up", undefined],
265
+ ] as const)(
266
+ "shows the stop after %s and resumes on an explicit reconnect",
267
+ async (_name, status) => {
268
+ const { connect, opened, attempts, client, store, checkHealth } = setup();
269
+ connect(lifetime().signal);
270
+ const first = await opened();
271
+ first.receive({ event: "ready", id: "5", data: {} });
272
+ first.stop(status);
273
+ await vi.waitFor(() =>
274
+ expect(store.get(client.connectionAtom)).toEqual(
275
+ status ?? {
276
+ state: ConnectionState.CLOSED,
277
+ code: "KERNEL_DISCONNECTED",
278
+ reason: "kernel not found",
279
+ },
280
+ ),
277
281
  );
278
- expect(transport.reconnect).toHaveBeenCalledOnce();
282
+ expect(attempts).toHaveLength(1);
279
283
  await client.reconnect();
280
284
  expect(checkHealth).toHaveBeenCalledOnce();
281
- expect(transport.reconnect).toHaveBeenCalledTimes(2);
282
- lifetime.abort();
285
+ expect((await opened()).cursor).toBe("5");
283
286
  },
284
287
  );
285
288
 
286
- it("only resets the automatic retry budget after an open event", () => {
287
- const { connect, events, transport } = setup();
288
- const lifetime = new AbortController();
289
- connect(lifetime.signal);
290
- events.dispatchEvent(new CloseEvent("close"));
291
- events.dispatchEvent(new Event("error"));
292
- events.dispatchEvent(new CloseEvent("close"));
293
- expect(transport.reconnect).toHaveBeenCalledOnce();
294
- events.dispatchEvent(new Event("open"));
295
- events.dispatchEvent(new CloseEvent("close"));
296
- expect(transport.reconnect).toHaveBeenCalledTimes(2);
297
- lifetime.abort();
298
- });
299
-
300
289
  it("does not revive an aborted lifetime when a manual health check finishes", async () => {
301
- const { connect, client, transport, checkHealth } = setup();
290
+ const { connect, opened, attempts, client, checkHealth } = setup();
302
291
  const health = new Deferred<boolean>();
303
292
  checkHealth.mockReturnValue(health.promise);
304
- const lifetime = new AbortController();
305
- connect(lifetime.signal);
293
+ const current = lifetime();
294
+ connect(current.signal);
295
+ const first = await opened();
296
+ first.stop({
297
+ state: ConnectionState.CLOSED,
298
+ code: "KERNEL_DISCONNECTED",
299
+ reason: "",
300
+ });
301
+ await vi.waitFor(() => expect(checkHealth).not.toHaveBeenCalled());
306
302
  const attempt = client.reconnect();
307
- lifetime.abort();
303
+ current.abort();
308
304
  health.resolve(true);
309
305
  await attempt;
310
- expect(transport.reconnect).not.toHaveBeenCalled();
306
+ expect(attempts).toHaveLength(1);
311
307
  });
312
308
 
313
- it("does not connect after abort, even when the health check finishes", async () => {
314
- const { client, connect, healthy, transport } = setup();
315
- const controller = new AbortController();
316
- connect(controller.signal);
317
- controller.abort();
318
- healthy.resolve();
319
- await healthy.promise;
320
- client.reconnect();
321
- expect(transport.reconnect).not.toHaveBeenCalled();
322
- expect(transport.close).toHaveBeenCalledOnce();
309
+ it("discards an interrupted snapshot before a manual reconnect", async () => {
310
+ const { connect, opened, client, store } = setup();
311
+ connect(lifetime().signal);
312
+ const first = await opened();
313
+ const snapshot = fixtureEvents("notebook-events.sse");
314
+ for (const event of snapshot) {
315
+ if (event.event === "ready") {
316
+ break;
317
+ }
318
+ first.receive(event);
319
+ }
320
+ first.stop();
321
+ await vi.waitFor(() =>
322
+ expect(store.get(client.connectionAtom).state).toBe(
323
+ ConnectionState.CLOSED,
324
+ ),
325
+ );
326
+
327
+ await client.reconnect();
328
+ const second = await opened();
329
+ expect(second.cursor).toBeNull();
330
+ for (const event of snapshot) {
331
+ if (event.event === "notebook") {
332
+ second.receive(event);
333
+ }
334
+ if (event.event === "document") {
335
+ second.receive({
336
+ ...event,
337
+ data: { document: { ...event.data.document, order: [] } },
338
+ });
339
+ }
340
+ }
341
+ second.receive({ event: "ready", id: "100", data: {} });
342
+ expect({
343
+ connection: store.get(client.connectionAtom),
344
+ order: store.get(client.documentAtom)?.revision.order,
345
+ attachments: store.get(client.attachmentsAtom),
346
+ }).toEqual({
347
+ connection: { state: ConnectionState.OPEN },
348
+ order: [],
349
+ attachments: [],
350
+ });
323
351
  });
324
352
 
325
- it("detaches listeners before closing so cleanup cannot trigger a session retry", () => {
326
- const { connect, client, store, events, transport, onNotification } =
327
- setup();
328
- const controller = new AbortController();
329
- connect(controller.signal);
330
- controller.abort();
331
- events.dispatchEvent(
332
- new MessageEvent("message", {
333
- data: JSON.stringify({ data: { op: "reconnected" } }),
334
- }),
335
- );
336
- expect(transport.reconnect).not.toHaveBeenCalled();
353
+ it("does not connect after abort", async () => {
354
+ const { connect, attempts, client, store } = setup();
355
+ const current = lifetime();
356
+ connect(current.signal);
357
+ current.abort();
358
+ await client.reconnect();
359
+ await new Promise((resolve) => setTimeout(resolve, 10));
360
+ expect(attempts).toHaveLength(0);
337
361
  expect(store.get(client.connectionAtom)).toEqual({
338
362
  state: ConnectionState.NOT_STARTED,
339
363
  });
340
- expect(onNotification).not.toHaveBeenCalled();
341
364
  });
342
365
 
343
- it("can reconnect after effect cleanup without reviving the earlier client attempt", async () => {
344
- const { connect, healthy, transport, events, onNotification } = setup();
345
- const first = new AbortController();
366
+ it("forwards notifications for the current lifetime only", async () => {
367
+ const { connect, opened, onNotification } = setup();
368
+ const first = lifetime();
346
369
  connect(first.signal);
370
+ const earlier = await opened();
347
371
  first.abort();
348
- const second = new AbortController();
349
- connect(second.signal);
350
- healthy.resolve();
351
- await healthy.promise;
352
- expect(transport.reconnect).toHaveBeenCalledOnce();
353
- events.dispatchEvent(
354
- new MessageEvent("message", {
355
- data: JSON.stringify({
356
- event: "marimo",
357
- data: { marimo: { op: "reconnected" } },
358
- }),
359
- }),
360
- );
372
+ connect(lifetime().signal);
373
+ const current = await opened();
374
+ earlier.receive(kernelReady);
375
+ current.receive({
376
+ event: "marimo",
377
+ data: { marimo: { op: "reconnected" } },
378
+ });
361
379
  expect(onNotification).toHaveBeenCalledExactlyOnceWith({
362
380
  data: { op: "reconnected" },
363
381
  });
364
- second.abort();
365
382
  });
366
383
  });
367
384
 
368
- async function open() {
369
- const url = vi.fn(() => "ws://localhost/ws?session_id=first&cursor=999");
370
- const setupResult = setup(url);
371
- const lifetime = new AbortController();
372
- setupResult.connect(lifetime.signal);
373
- setupResult.healthy.resolve();
374
- await setupResult.healthy.promise;
375
- setupResult.events.dispatchEvent(new Event("open"));
376
- return { ...setupResult, lifetime, url };
377
- }
378
-
379
385
  describe("NotebookClient attachments", () => {
380
386
  const [browser, agent] = fixtureEvents("notebook-events.sse").flatMap(
381
387
  (event) => (event.event === "attachment" ? [event.data.attachment] : []),
382
388
  );
383
389
 
390
+ async function open() {
391
+ const result = setup();
392
+ result.connect(lifetime().signal);
393
+ return { ...result, attempt: await result.opened() };
394
+ }
395
+
384
396
  it("publishes a complete snapshot and applies live changes independently of kernel readiness", async () => {
385
- const { client, store, receive, lifetime } = await open();
397
+ const { client, store, attempt } = await open();
386
398
  const snapshot = fixtureMessages("notebook-events.sse");
387
399
  const readyIndex = snapshot.findIndex((event) => event.event === "ready");
388
- snapshot.slice(0, readyIndex).forEach(receive);
400
+ snapshot.slice(0, readyIndex).forEach(attempt.receive);
389
401
  expect(store.get(client.attachmentsAtom)).toEqual([]);
390
- snapshot.slice(readyIndex).forEach(receive);
402
+ snapshot.slice(readyIndex).forEach(attempt.receive);
391
403
  expect(store.get(client.attachmentsAtom)).toEqual([browser, agent]);
392
404
  const renamed = { ...agent, name: "Renamed agent" };
393
- receive({ event: "attachment", id: "98", data: { attachment: renamed } });
394
- receive({
405
+ attempt.receive({
406
+ event: "attachment",
407
+ id: "98",
408
+ data: { attachment: renamed },
409
+ });
410
+ attempt.receive({
395
411
  event: "attachment.removed",
396
412
  id: "99",
397
413
  data: { id: browser.id },
398
414
  });
399
415
  expect(store.get(client.attachmentsAtom)).toEqual([renamed]);
400
- receive({ data: { op: "kernel-ready", resumed: false } });
416
+ attempt.receive(kernelReady);
401
417
  expect(store.get(client.attachmentsAtom)).toEqual([renamed]);
402
- receive({ event: "attachment.removed", id: "100", data: { id: agent.id } });
403
- expect(store.get(client.attachmentsAtom)).toEqual([]);
404
- lifetime.abort();
405
- });
406
-
407
- it("resumes attachments alongside an incomplete document and waits for replay ready", async () => {
408
- const { client, store, receive, events, urls, lifetime } = await open();
409
- expect(urls[0].searchParams.has("cursor")).toBe(false);
410
- fixtureMessages("notebook-events.sse").forEach(receive);
411
- const confirmed = store.get(client.documentAtom);
412
- const edits = fixtureMessages("edits.sse");
413
- edits.slice(0, 3).forEach(receive);
414
- const renamed = { ...agent, name: "Renamed agent" };
415
- receive({ event: "attachment", id: "112a", data: { attachment: renamed } });
416
- expect(store.get(client.attachmentsAtom)).toEqual([browser, renamed]);
417
- events.dispatchEvent(new CloseEvent("close"));
418
- expect(store.get(client.attachmentsAtom)).toEqual([]);
419
- expect(urls.at(-1)!.searchParams.get("cursor")).toBe("112a");
420
- events.dispatchEvent(new Event("open"));
421
- expect(store.get(client.attachmentsAtom)).toEqual([]);
422
- expect(store.get(client.documentAtom)).toBe(confirmed);
423
- edits.slice(3).forEach(receive);
424
- receive({
418
+ attempt.receive({
425
419
  event: "attachment.removed",
426
- id: "115",
427
- data: { id: browser.id },
420
+ id: "100",
421
+ data: { id: agent.id },
428
422
  });
429
423
  expect(store.get(client.attachmentsAtom)).toEqual([]);
430
- receive({ event: "ready", id: "116", data: {} });
431
- expect(store.get(client.attachmentsAtom)).toEqual([renamed]);
432
- const revisions = fixtureEvents("edits.sse").flatMap((event) =>
433
- event.event === "document" ? [event.data.document] : [],
434
- );
435
- expect(store.get(client.documentAtom)?.revision).toEqual(revisions.at(-1));
436
- lifetime.abort();
437
424
  });
438
425
 
439
- it("replaces stale attachments with an empty snapshot after reset", async () => {
440
- const { client, store, receive, lifetime } = await open();
441
- fixtureMessages("notebook-events.sse").forEach(receive);
442
- receive({ event: "reset", data: {} });
443
- expect(store.get(client.attachmentsAtom)).toEqual([]);
444
- fixtureMessages("notebook-events.sse")
445
- .filter((event) => event.event !== "attachment")
446
- .forEach(receive);
447
- expect(store.get(client.attachmentsAtom)).toEqual([]);
448
- lifetime.abort();
449
- });
426
+ it.each(["automatic", "manual"])(
427
+ "preserves an incomplete live document across %s reconnect and waits for replay ready",
428
+ async (recovery) => {
429
+ vi.useFakeTimers();
430
+ const { client, store, attempt, opened } = await open();
431
+ expect(attempt.cursor).toBeNull();
432
+ fixtureMessages("notebook-events.sse").forEach(attempt.receive);
433
+ const confirmed = store.get(client.documentAtom);
434
+ const edits = fixtureMessages("edits.sse");
435
+ edits.slice(0, 3).forEach(attempt.receive);
436
+ const renamed = { ...agent, name: "Renamed agent" };
437
+ attempt.receive({
438
+ event: "attachment",
439
+ id: "112a",
440
+ data: { attachment: renamed },
441
+ });
442
+ expect(store.get(client.attachmentsAtom)).toEqual([browser, renamed]);
443
+ if (recovery === "automatic") {
444
+ attempt.drop();
445
+ await backoff();
446
+ } else {
447
+ attempt.stop();
448
+ await vi.advanceTimersByTimeAsync(0);
449
+ await client.reconnect();
450
+ }
451
+ expect(store.get(client.attachmentsAtom)).toEqual([]);
452
+ const replay = await opened();
453
+ expect(replay.cursor).toBe("112a");
454
+ expect(store.get(client.attachmentsAtom)).toEqual([]);
455
+ expect(store.get(client.documentAtom)).toBe(confirmed);
456
+ edits.slice(3).forEach(replay.receive);
457
+ replay.receive({
458
+ event: "attachment.removed",
459
+ id: "115",
460
+ data: { id: browser.id },
461
+ });
462
+ expect(store.get(client.attachmentsAtom)).toEqual([]);
463
+ replay.receive({ event: "ready", id: "116", data: {} });
464
+ expect(store.get(client.attachmentsAtom)).toEqual([renamed]);
465
+ const revisions = fixtureEvents("edits.sse").flatMap((event) =>
466
+ event.event === "document" ? [event.data.document] : [],
467
+ );
468
+ expect(store.get(client.documentAtom)?.revision).toEqual(
469
+ revisions.at(-1),
470
+ );
471
+ },
472
+ );
450
473
 
451
- it.each(["close", "error", "malformed", "removed", "abort", "identity"])(
474
+ it.each(["drop", "stop", "removed", "abort"])(
452
475
  "clears presence on %s and ignores messages after disposal",
453
476
  async (reason) => {
454
- const { client, store, receive, events, url, lifetime } = await open();
455
- fixtureMessages("notebook-events.sse").forEach(receive);
477
+ vi.useFakeTimers();
478
+ const current = lifetime();
479
+ const result = setup();
480
+ result.connect(current.signal);
481
+ const { client, store } = result;
482
+ const attempt = await result.opened();
483
+ fixtureMessages("notebook-events.sse").forEach(attempt.receive);
456
484
  expect(store.get(client.attachmentsAtom)).toEqual([browser, agent]);
457
485
  switch (reason) {
458
- case "close":
459
- events.dispatchEvent(
460
- new CloseEvent("close", { reason: "MARIMO_UNAUTHORIZED" }),
461
- );
486
+ case "drop":
487
+ attempt.drop();
488
+ await backoff();
462
489
  break;
463
- case "error":
464
- events.dispatchEvent(new Event("error"));
465
- break;
466
- case "malformed":
467
- receive({ event: "attachment", id: "98", data: {} });
490
+ case "stop":
491
+ attempt.stop({
492
+ state: ConnectionState.CLOSED,
493
+ code: "KERNEL_DISCONNECTED",
494
+ reason: "not authorized",
495
+ });
496
+ await vi.advanceTimersByTimeAsync(0);
468
497
  break;
469
498
  case "removed":
470
- receive({ event: "notebook.removed", id: "98", data: { id: "n1" } });
471
- break;
472
- case "identity":
473
- url.mockReturnValue(
474
- "ws://localhost/api/marimo/v1/notebooks/n2/events/ws",
475
- );
476
- events.dispatchEvent(new CloseEvent("close"));
477
- receive({ event: "ready", id: "98", data: {} });
499
+ attempt.receive({
500
+ event: "notebook.removed",
501
+ id: "98",
502
+ data: { id: "n1" },
503
+ });
478
504
  break;
479
505
  default:
480
- lifetime.abort();
506
+ current.abort();
481
507
  }
482
508
  expect(store.get(client.attachmentsAtom)).toEqual([]);
483
- lifetime.abort();
484
- fixtureMessages("notebook-events.sse").forEach(receive);
509
+ current.abort();
510
+ fixtureMessages("notebook-events.sse").forEach(attempt.receive);
485
511
  expect(store.get(client.attachmentsAtom)).toEqual([]);
486
512
  },
487
513
  );
488
-
489
- it("keeps host resources empty for notification-only WASM connections", async () => {
490
- const { client, store, receive, lifetime } = await open();
491
- receive({ data: { op: "kernel-ready" } });
492
- receive({ data: { op: "reconnected" } });
493
- expect(store.get(client.attachmentsAtom)).toEqual([]);
494
- lifetime.abort();
495
- });
496
514
  });