@marimo-team/frontend 0.25.3-dev79 → 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 (101) 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-B5H-9JMB.js → command-palette-OOQHBTnr.js} +1 -1
  22. package/dist/assets/{common-DeeLgjWQ.js → common-D7VAS6rd.js} +1 -1
  23. package/dist/assets/components-CJRBUI2s.js +1 -0
  24. package/dist/assets/{components-DyAF3_Nf.js → components-CNmvC781.js} +1 -1
  25. package/dist/assets/config-BQT0uYUo.js +1 -0
  26. package/dist/assets/{config-CjQ4WJnJ.js → config-BbRbT9Ww.js} +1 -1
  27. package/dist/assets/{datasource-gqFgG9Qd.js → datasource-C3yfeDa2.js} +1 -1
  28. package/dist/assets/{dependency-graph-panel-BFFxLgR6.js → dependency-graph-panel-CjoA6L0w.js} +1 -1
  29. package/dist/assets/{documentation-panel-BBpGbT-f.js → documentation-panel-DHWFOiG1.js} +1 -1
  30. package/dist/assets/{download-C8Gw-D0Y.js → download-BrCpRZKN.js} +1 -1
  31. package/dist/assets/{edit-page-D5LKeAwW.js → edit-page-DafDcIAI.js} +6 -6
  32. package/dist/assets/{error-entries-uyqOG5tg.js → error-entries-BkEAmmnD.js} +1 -1
  33. package/dist/assets/{error-panel-Cli5zPWi.js → error-panel-BhTfPA_i.js} +1 -1
  34. package/dist/assets/feedback-button-BAcClv7c.js +1 -0
  35. package/dist/assets/{feedback-button-CvrwBGr7.js → feedback-button-D9InK87G.js} +1 -1
  36. package/dist/assets/{file-explorer-panel-BSU5ultY.js → file-explorer-panel-BSd_ZbUC.js} +1 -1
  37. package/dist/assets/{file-name-input-CDrRlIiX.js → file-name-input-RHURglL_.js} +1 -1
  38. package/dist/assets/{floating-outline-GQD9km0W.js → floating-outline-Cm49rYYe.js} +1 -1
  39. package/dist/assets/{gallery-page-CxmV1JLF.js → gallery-page-DdMdOtdv.js} +1 -1
  40. package/dist/assets/{glide-data-editor-B1kpoetH.js → glide-data-editor-eiSlEUyR.js} +1 -1
  41. package/dist/assets/home-page-DwWvBB0R.js +1 -0
  42. package/dist/assets/{hooks-BrFYiTJS.js → hooks-C4GjmtQ-.js} +1 -1
  43. package/dist/assets/index-BdsOMJac.css +2 -0
  44. package/dist/assets/{index-BU1n9Awq.js → index-jR_KRDMC.js} +8 -8
  45. package/dist/assets/{layout-CGC8ajpe.js → layout-Bq7wRzdR.js} +3 -3
  46. package/dist/assets/{logs-panel-CdUc5vtA.js → logs-panel-CAAk72Gb.js} +1 -1
  47. package/dist/assets/{markdown-renderer-pKc_bFac.js → markdown-renderer-CAyCgogC.js} +1 -1
  48. package/dist/assets/{name-cell-input-csH3Cy54.js → name-cell-input-DA8hU9XT.js} +1 -1
  49. package/dist/assets/{outline-panel-DhsHYTuo.js → outline-panel-Cc5oQ4LL.js} +1 -1
  50. package/dist/assets/{packages-panel-BtkXVz3L.js → packages-panel-DTfKez6I.js} +1 -1
  51. package/dist/assets/{panel-accordion-state-Bco6nsUI.js → panel-accordion-state-BZHex38W.js} +1 -1
  52. package/dist/assets/panels-Cnu2Flfw.js +105 -0
  53. package/dist/assets/{process-output-B2-bT4cq.js → process-output-CrDc_O1D.js} +1 -1
  54. package/dist/assets/{readonly-code-display-Dsp6vWPh.js → readonly-code-display-BR-_jUj8.js} +1 -1
  55. package/dist/assets/{readonly-python-code-B81AjzXM.js → readonly-python-code-D7jcy9ds.js} +1 -1
  56. package/dist/assets/{reveal-component-Cxo3hLGA.js → reveal-component-BWu6kkxZ.js} +1 -1
  57. package/dist/assets/{run-page-CLP1s8uX.js → run-page-BJq9MULx.js} +1 -1
  58. package/dist/assets/{runtime-details-state-CznIe40L.js → runtime-details-state-hb7A1jgR.js} +1 -1
  59. package/dist/assets/{scratchpad-panel-yHIW63VQ.js → scratchpad-panel-OHQ7Z0RY.js} +1 -1
  60. package/dist/assets/{session-panel-C7-wmZhc.js → session-panel-DmDjaPsN.js} +1 -1
  61. package/dist/assets/{snippets-panel-BARpOPyS.js → snippets-panel-o9KL5jMi.js} +1 -1
  62. package/dist/assets/state-BkAqJoW7.js +3 -0
  63. package/dist/assets/{state-Bye6rCo4.js → state-Os7LSUUZ.js} +1 -1
  64. package/dist/assets/{terminal-Bhcn_y1N.js → terminal-BI85khrS.js} +1 -1
  65. package/dist/assets/tracing-AsNGuDsh.js +1 -0
  66. package/dist/assets/{tracing-panel-UKdh_YXC.js → tracing-panel-CBcmea6L.js} +2 -2
  67. package/dist/assets/{tree-actions-Cb-nUK1F.js → tree-actions-Cx5EDpA6.js} +1 -1
  68. package/dist/assets/{useAddCell-Bielffe0.js → useAddCell-uJJYtgwa.js} +1 -1
  69. package/dist/assets/{useBoolean-CMgj9hnq.js → useBoolean-8wlrTeus.js} +1 -1
  70. package/dist/assets/useCellActionButton-CmQ-OcPa.js +1 -0
  71. package/dist/assets/{useDeleteCell-BV4VemCv.js → useDeleteCell-CXgOCDqY.js} +1 -1
  72. package/dist/assets/{useDependencyPanelTab-BEGqRiWi.js → useDependencyPanelTab-Ch6gm7uM.js} +1 -1
  73. package/dist/assets/{useInstallPackage-C7neMPwh.js → useInstallPackage-CYQoMdMQ.js} +1 -1
  74. package/dist/assets/useNotebookActions-CqXa3U6u.js +1 -0
  75. package/dist/assets/{useRunCells-BFeZMi7f.js → useRunCells-DAfDmOMC.js} +1 -1
  76. package/dist/assets/{useSplitCell-DllkNFke.js → useSplitCell-NLULgcq7.js} +1 -1
  77. package/dist/assets/{vega-component-D7JFthDD.js → vega-component-BKJU3uaB.js} +1 -1
  78. package/dist/index.html +25 -25
  79. package/package.json +1 -1
  80. package/src/components/home/components.tsx +17 -31
  81. package/src/components/pages/home-page.tsx +141 -89
  82. package/src/core/host/__tests__/notebook-client.test.ts +368 -350
  83. package/src/core/host/notebook-client.ts +129 -186
  84. package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +133 -59
  85. package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +152 -250
  86. package/src/core/websocket/transports/__tests__/notebook.test.ts +502 -0
  87. package/src/core/websocket/transports/notebook.ts +213 -0
  88. package/src/core/websocket/useNotebookClient.ts +28 -17
  89. package/src/css/globals.css +3 -0
  90. package/dist/assets/circle-play-CYIb0qT6.js +0 -1
  91. package/dist/assets/components-lDm0qi2t.js +0 -1
  92. package/dist/assets/config-DzdxZb17.js +0 -1
  93. package/dist/assets/feedback-button-DknbRCN_.js +0 -1
  94. package/dist/assets/home-page-CZ3hkD3m.js +0 -4
  95. package/dist/assets/index-evFWHKmM.css +0 -2
  96. package/dist/assets/link-NuFVR7G9.js +0 -1
  97. package/dist/assets/panels-D1i80TGn.js +0 -107
  98. package/dist/assets/state-BRYi026f.js +0 -3
  99. package/dist/assets/tracing-BW2ZVnXs.js +0 -1
  100. package/dist/assets/useCellActionButton-Cu34Zs6x.js +0 -1
  101. package/dist/assets/useNotebookActions-CmqOx-WZ.js +0 -1
@@ -21,8 +21,8 @@ import {
21
21
  vi,
22
22
  } from "vitest";
23
23
 
24
- vi.mock("../transports/create", () => ({
25
- createConnectionTransport: vi.fn(),
24
+ vi.mock("../transports/notebook", () => ({
25
+ createNotebookTransport: vi.fn(),
26
26
  }));
27
27
 
28
28
  vi.mock("@/core/runtime/config", async () => {
@@ -36,7 +36,6 @@ vi.mock("@/core/runtime/config", async () => {
36
36
  });
37
37
 
38
38
  import { MockNotebook } from "@/__mocks__/notebook";
39
- import { Logger } from "@/utils/Logger";
40
39
  import { Deferred } from "@/utils/Deferred";
41
40
  import { cellId } from "@/__tests__/branded";
42
41
  import { alertAtom, getPackageAlert } from "@/core/alerts/state";
@@ -44,7 +43,6 @@ import { notebookAtom } from "@/core/cells/cells";
44
43
  import { AppConfigSchema } from "@/core/config/config-schema";
45
44
  import { ConnectionNotice } from "@/components/editor/alerts/connection-notice";
46
45
  import { kernelStartupErrorAtom } from "@/core/errors/state";
47
- import type { RunEvent } from "@marimo-team/client/protocol";
48
46
  import type { NotificationPayload } from "@/core/kernel/messages";
49
47
  import { useRuntimeManager } from "@/core/runtime/config";
50
48
  import { initialRunCompletedAtom } from "../../kernel/state";
@@ -52,54 +50,72 @@ import { connectionAtom, startupProgressAtom } from "../../network/connection";
52
50
  import { generateSessionId } from "../../kernel/session";
53
51
  import { RuntimeManager } from "../../runtime/runtime";
54
52
  import { WebSocketClosedReason, ConnectionState } from "../types";
55
- import type { IConnectionTransport } from "../transports/transport";
53
+ import { classifyCloseEvent } from "../connection-state";
54
+ import { createNotebookTransport } from "../transports/notebook";
56
55
  import { useNotebookSession } from "../useNotebookSession";
57
- import { createConnectionTransport } from "../transports/create";
56
+ import { type NotebookTransport, StreamStopped } from "@marimo-team/client";
58
57
 
59
58
  const SESSION_ID = generateSessionId();
60
59
 
61
- interface MockTransport {
62
- readyState: 0 | 1 | 2 | 3;
63
- reconnect: Mock<IConnectionTransport["reconnect"]>;
64
- close: Mock<IConnectionTransport["close"]>;
65
- send: Mock<IConnectionTransport["send"]>;
66
- addEventListener: Mock<IConnectionTransport["addEventListener"]>;
67
- removeEventListener: Mock<IConnectionTransport["removeEventListener"]>;
60
+ /** One connection the fake transport opened. */
61
+ interface Attempt {
62
+ open(): void;
63
+ receive(message: unknown): void;
64
+ drop(): void;
65
+ stop(error: StreamStopped): void;
68
66
  }
69
67
 
70
- function makeTransport(
71
- readyState: 0 | 1 | 2 | 3 = WebSocket.CLOSED,
72
- ): MockTransport {
73
- return {
74
- readyState,
75
- reconnect: vi.fn(),
76
- close: vi.fn(),
77
- send: vi.fn(),
78
- addEventListener: vi.fn(),
79
- removeEventListener: vi.fn(),
80
- };
68
+ /** A transport the test drives, one connection at a time. */
69
+ function makeTransport() {
70
+ const attempts: Attempt[] = [];
71
+ const transport: NotebookTransport = ({ signal }, handlers) =>
72
+ new Promise((resolve, reject) => {
73
+ attempts.push({
74
+ open: handlers.open,
75
+ receive: handlers.message,
76
+ drop: resolve,
77
+ stop: reject,
78
+ });
79
+ signal.addEventListener("abort", () => resolve(), { once: true });
80
+ });
81
+ return { transport, attempts };
81
82
  }
82
83
 
83
- function getTransportHandlers() {
84
- const result = vi.mocked(createConnectionTransport).mock.results.at(-1);
85
- if (!result || result.type !== "return") {
86
- throw new Error("No connection transport was created");
87
- }
88
- const transport = result.value;
89
- function emit(
90
- event: "open" | "close" | "error" | "message",
91
- message: Event | CloseEvent | MessageEvent,
92
- ) {
93
- const callback = vi
94
- .mocked(transport.addEventListener)
95
- .mock.calls.find(([name]) => name === event)?.[1];
96
- callback?.(message);
97
- }
84
+ type FakeTransport = ReturnType<typeof makeTransport>;
85
+
86
+ /** Use a fake transport for the next session the hook follows. */
87
+ function withTransport(fake: FakeTransport = makeTransport()) {
88
+ vi.mocked(createNotebookTransport).mockReturnValue(fake.transport);
89
+ return fake;
90
+ }
91
+
92
+ /** Let the stream open its first connection. */
93
+ const started = () => act(() => Promise.resolve());
94
+
95
+ /** Drive the latest connection the fake transport opened. */
96
+ function connection(fake: FakeTransport) {
97
+ const current = () => {
98
+ const attempt = fake.attempts.at(-1);
99
+ if (attempt === undefined) {
100
+ throw new Error("No connection was opened");
101
+ }
102
+ return attempt;
103
+ };
98
104
  return {
99
- onError: (event: Event) => emit("error", event),
100
- onOpen: (event: Event) => emit("open", event),
101
- onClose: (event: CloseEvent) => emit("close", event),
102
- onMessage: (event: MessageEvent) => emit("message", event),
105
+ open: () => current().open(),
106
+ receive: (message: unknown) => current().receive(message),
107
+ /** Deliver a notification as the session routes frame it. */
108
+ send: (notification: unknown) =>
109
+ current().receive({ event: "marimo", data: { marimo: notification } }),
110
+ /** End the connection so the stream reconnects. */
111
+ drop: () => current().drop(),
112
+ /** Close the connection with a reason the editor treats as final. */
113
+ close: (reason: string) =>
114
+ current().stop(
115
+ new StreamStopped(reason, {
116
+ cause: classifyCloseEvent({ reason }).status,
117
+ }),
118
+ ),
103
119
  };
104
120
  }
105
121
 
@@ -141,12 +157,12 @@ function renderConnectionHook(store: ReturnType<typeof createStore>) {
141
157
  }
142
158
 
143
159
  describe("useNotebookSession.reconnect()", () => {
144
- let transport: MockTransport;
160
+ let fake: FakeTransport;
145
161
  let isHealthy: Mock<() => Promise<boolean>>;
146
162
  let store: ReturnType<typeof createStore>;
147
163
 
148
164
  beforeEach(() => {
149
- transport = makeTransport(WebSocket.CLOSED);
165
+ fake = withTransport();
150
166
  isHealthy = vi.fn<() => Promise<boolean>>().mockResolvedValue(true);
151
167
  store = createStore();
152
168
  store.set(connectionAtom, {
@@ -154,55 +170,53 @@ describe("useNotebookSession.reconnect()", () => {
154
170
  code: WebSocketClosedReason.KERNEL_DISCONNECTED,
155
171
  reason: "kernel not found",
156
172
  });
157
- vi.mocked(createConnectionTransport).mockReturnValue(transport);
158
173
  vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager(isHealthy));
159
174
  });
160
175
 
161
- function renderUseHook() {
162
- return renderConnectionHook(store);
176
+ async function renderStopped() {
177
+ const hook = renderConnectionHook(store);
178
+ await started();
179
+ await act(async () => connection(fake).close("MARIMO_SHUTDOWN"));
180
+ return hook;
163
181
  }
164
182
 
165
- it("is a no-op when the transport is already OPEN", async () => {
166
- transport.readyState = WebSocket.OPEN;
167
- const { result } = renderUseHook();
168
- await act(async () => {
169
- await result.current.reconnect();
170
- });
171
- expect(isHealthy).not.toHaveBeenCalled();
172
- expect(transport.reconnect).not.toHaveBeenCalled();
173
- });
174
-
175
- it("is a no-op when the transport is already CONNECTING", async () => {
176
- transport.readyState = WebSocket.CONNECTING;
177
- const { result } = renderUseHook();
178
- await act(async () => {
179
- await result.current.reconnect();
180
- });
181
- expect(isHealthy).not.toHaveBeenCalled();
182
- expect(transport.reconnect).not.toHaveBeenCalled();
183
- });
183
+ it.each(["connecting", "connected"])(
184
+ "is a no-op while the stream is %s",
185
+ async (state) => {
186
+ const { result } = renderConnectionHook(store);
187
+ await started();
188
+ if (state === "connected") {
189
+ act(() => connection(fake).open());
190
+ }
191
+ await act(async () => {
192
+ await result.current.reconnect();
193
+ });
194
+ expect(isHealthy).not.toHaveBeenCalled();
195
+ expect(fake.attempts).toHaveLength(1);
196
+ },
197
+ );
184
198
 
185
199
  it("probes /health and reconnects when the runtime is healthy", async () => {
186
200
  isHealthy.mockResolvedValue(true);
187
- const { result } = renderUseHook();
201
+ const { result } = await renderStopped();
188
202
  await act(async () => {
189
203
  await result.current.reconnect();
190
204
  });
191
205
  expect(isHealthy).toHaveBeenCalledOnce();
192
- expect(transport.reconnect).toHaveBeenCalledOnce();
206
+ expect(fake.attempts).toHaveLength(2);
193
207
  expect(store.get(connectionAtom)).toEqual({
194
208
  state: ConnectionState.CONNECTING,
195
209
  });
196
210
  });
197
211
 
198
- it("transitions to CLOSED and does not call ws.reconnect when the probe fails", async () => {
212
+ it("transitions to CLOSED and does not reconnect when the probe fails", async () => {
199
213
  isHealthy.mockResolvedValue(false);
200
- const { result } = renderUseHook();
214
+ const { result } = await renderStopped();
201
215
  await act(async () => {
202
216
  await result.current.reconnect();
203
217
  });
204
218
  expect(isHealthy).toHaveBeenCalledOnce();
205
- expect(transport.reconnect).not.toHaveBeenCalled();
219
+ expect(fake.attempts).toHaveLength(1);
206
220
  expect(store.get(connectionAtom)).toEqual({
207
221
  state: ConnectionState.CLOSED,
208
222
  code: WebSocketClosedReason.KERNEL_DISCONNECTED,
@@ -213,115 +227,30 @@ describe("useNotebookSession.reconnect()", () => {
213
227
 
214
228
  describe("useNotebookSession messages", () => {
215
229
  let store: ReturnType<typeof createStore>;
230
+ let fake: FakeTransport;
216
231
 
217
- beforeEach(() => {
232
+ beforeEach(async () => {
218
233
  store = createStore();
219
- vi.mocked(createConnectionTransport).mockClear();
220
- vi.mocked(createConnectionTransport).mockReturnValue(
221
- makeTransport(WebSocket.OPEN),
222
- );
234
+ fake = withTransport();
223
235
  vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
224
236
  renderConnectionHook(store);
237
+ await started();
225
238
  });
226
239
 
227
240
  function receive(payload: unknown) {
228
- const options = getTransportHandlers();
229
- act(() => {
230
- options.onMessage(
231
- new MessageEvent("message", { data: JSON.stringify(payload) }),
232
- );
233
- });
241
+ act(() => connection(fake).receive(payload));
234
242
  }
235
243
 
236
- it.each(["{broken", "null"])(
237
- "closes a malformed stream and ignores subsequent notifications: %s",
238
- (data) => {
239
- receive({ event: "marimo", data: { marimo: { op: "reconnected" } } });
240
- act(() =>
241
- getTransportHandlers().onMessage(new MessageEvent("message", { data })),
242
- );
243
- expect(store.get(connectionAtom)).toEqual({
244
- state: ConnectionState.CLOSED,
245
- code: "KERNEL_DISCONNECTED",
246
- reason: "Failed to decode notebook message",
247
- });
248
- receive({
249
- event: "marimo",
250
- data: {
251
- marimo: {
252
- op: "completed-run",
253
- run_id: "r1",
254
- outcome: "succeeded",
255
- error: null,
256
- },
257
- },
258
- });
259
- expect(store.get(initialRunCompletedAtom)).toBe(false);
260
- },
261
- );
262
-
263
- it.each(["session", "host"])(
264
- "records completion of the initial run from the %s stream",
265
- (stream) => {
266
- const notification = {
267
- op: "completed-run",
268
- run_id: null,
269
- outcome: "succeeded",
270
- error: null,
271
- } satisfies NotificationPayload["data"];
272
- expect(store.get(initialRunCompletedAtom)).toBe(false);
273
- receive(
274
- stream === "host"
275
- ? { id: "42", event: "marimo", data: { marimo: notification } }
276
- : { op: notification.op, data: notification },
277
- );
278
- expect(store.get(initialRunCompletedAtom)).toBe(true);
279
- },
280
- );
281
-
282
- it("ignores other host events before handling the next notification", () => {
283
- const errors = vi.spyOn(Logger, "error").mockImplementation(vi.fn());
284
- try {
285
- receive({ id: "1", event: "ready", data: {} });
286
- receive({
287
- id: "2",
288
- event: "run",
289
- data: {
290
- run: {
291
- id: "r1",
292
- notebook_id: "n1",
293
- caused_by: null,
294
- status: "completed",
295
- outcome: "succeeded",
296
- started_at: "2026-10-19T12:00:00Z",
297
- completed_at: "2026-10-19T12:00:01Z",
298
- },
299
- },
300
- } satisfies RunEvent);
301
- receive({
302
- id: "3",
303
- event: "future-event",
304
- data: { marimo: { op: "completed-run", run_id: "r1" } },
305
- });
306
- receive({ id: "4", event: "another-event", data: null });
307
- expect(store.get(initialRunCompletedAtom)).toBe(false);
308
- receive({
309
- id: "5",
310
- event: "marimo",
311
- data: {
312
- marimo: {
313
- op: "completed-run",
314
- run_id: "r1",
315
- outcome: "succeeded",
316
- error: null,
317
- },
318
- },
319
- });
320
- expect(store.get(initialRunCompletedAtom)).toBe(true);
321
- expect(errors).not.toHaveBeenCalled();
322
- } finally {
323
- errors.mockRestore();
324
- }
244
+ it("records completion of the initial run", () => {
245
+ const notification = {
246
+ op: "completed-run",
247
+ run_id: null,
248
+ outcome: "succeeded",
249
+ error: null,
250
+ } satisfies NotificationPayload["data"];
251
+ expect(store.get(initialRunCompletedAtom)).toBe(false);
252
+ receive({ id: "42", event: "marimo", data: { marimo: notification } });
253
+ expect(store.get(initialRunCompletedAtom)).toBe(true);
325
254
  });
326
255
  });
327
256
 
@@ -330,45 +259,34 @@ it.each(["kernel-ready", "reconnected"])(
330
259
  async (op) => {
331
260
  const store = createStore();
332
261
  store.set(connectionAtom, { state: ConnectionState.CONNECTING });
333
- vi.mocked(createConnectionTransport).mockClear();
334
- vi.mocked(createConnectionTransport).mockReturnValue(
335
- makeTransport(WebSocket.OPEN),
336
- );
262
+ const fake = withTransport();
337
263
  vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
338
264
  renderConnectionHook(store);
339
- const options = getTransportHandlers();
265
+ await started();
266
+ const conn = connection(fake);
340
267
 
341
- await act(async () => {
342
- await options.onOpen(new Event("open"));
343
- });
268
+ await act(async () => conn.open());
344
269
  expect(store.get(connectionAtom).state).toBe(ConnectionState.CONNECTING);
345
270
 
346
- act(() => {
347
- options.onMessage(
348
- new MessageEvent("message", {
349
- data: JSON.stringify({
350
- op,
351
- data: {
352
- op,
353
- cell_ids: [],
354
- codes: [],
355
- names: [],
356
- configs: [],
357
- layout: null,
358
- resumed: true,
359
- ui_values: {},
360
- last_executed_code: {},
361
- last_execution_time: {},
362
- app_config: { width: "normal" },
363
- kiosk: false,
364
- capabilities: { terminal: false },
365
- auto_instantiated: false,
366
- consumer_capabilities: { edit: true, interact: true },
367
- },
368
- }),
369
- }),
370
- );
371
- });
271
+ act(() =>
272
+ conn.send({
273
+ op,
274
+ cell_ids: [],
275
+ codes: [],
276
+ names: [],
277
+ configs: [],
278
+ layout: null,
279
+ resumed: true,
280
+ ui_values: {},
281
+ last_executed_code: {},
282
+ last_execution_time: {},
283
+ app_config: { width: "normal" },
284
+ kiosk: false,
285
+ capabilities: { terminal: false },
286
+ auto_instantiated: false,
287
+ consumer_capabilities: { edit: true, interact: true },
288
+ }),
289
+ );
372
290
  expect(store.get(connectionAtom).state).toBe(ConnectionState.OPEN);
373
291
  },
374
292
  );
@@ -377,11 +295,10 @@ describe("connection notice", () => {
377
295
  beforeEach(() => vi.useFakeTimers());
378
296
  afterEach(() => vi.useRealTimers());
379
297
 
380
- function renderNotice() {
298
+ async function renderNotice() {
381
299
  const store = createStore();
382
300
  store.set(connectionAtom, { state: ConnectionState.CONNECTING });
383
- const transport = makeTransport(WebSocket.OPEN);
384
- vi.mocked(createConnectionTransport).mockReturnValue(transport);
301
+ const fake = withTransport();
385
302
  vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
386
303
  const Connection = () => {
387
304
  const { reconnect } = useNotebookSession({
@@ -403,20 +320,17 @@ describe("connection notice", () => {
403
320
  </ErrorBoundary>
404
321
  </JotaiProvider>,
405
322
  );
406
- const options = getTransportHandlers();
323
+ await started();
324
+ const conn = connection(fake);
325
+ // Messages arrive on an open connection.
326
+ act(() => conn.open());
407
327
  const send = (data: NotificationPayload["data"]) =>
408
- act(() => {
409
- options.onMessage(
410
- new MessageEvent("message", {
411
- data: JSON.stringify({ op: data.op, data }),
412
- }),
413
- );
414
- });
415
- return { store, transport, options, send };
328
+ act(() => conn.send(data));
329
+ return { store, fake, conn, send };
416
330
  }
417
331
 
418
- it("keeps elapsed time across startup phases and leaves reconnection to the footer once cells are available", () => {
419
- const { store, options, send } = renderNotice();
332
+ it("keeps elapsed time across startup phases and leaves reconnection to the footer once cells are available", async () => {
333
+ const { store, conn, send } = await renderNotice();
420
334
  send({
421
335
  op: "startup-progress",
422
336
  phase: "preparing-environment",
@@ -456,7 +370,7 @@ describe("connection notice", () => {
456
370
  expect(
457
371
  screen.queryByRole("region", { name: "Notebook startup" }),
458
372
  ).not.toBeInTheDocument();
459
- act(() => options.onClose(new CloseEvent("close")));
373
+ await act(async () => conn.drop());
460
374
  act(() => vi.advanceTimersByTime(500));
461
375
  expect(store.get(connectionAtom)).toEqual({
462
376
  state: ConnectionState.CONNECTING,
@@ -467,8 +381,8 @@ describe("connection notice", () => {
467
381
  ).not.toBeInTheDocument();
468
382
  });
469
383
 
470
- it("restores startup output before appending live chunks and resets it for a new attempt", () => {
471
- const { store, send } = renderNotice();
384
+ it("restores startup output before appending live chunks and resets it for a new attempt", async () => {
385
+ const { store, send } = await renderNotice();
472
386
  const snapshot = {
473
387
  op: "startup-progress",
474
388
  phase: "starting-kernel",
@@ -494,7 +408,7 @@ describe("connection notice", () => {
494
408
  });
495
409
 
496
410
  it("restores completed startup output without putting a connected kernel back into startup", async () => {
497
- const { store, options, send } = renderNotice();
411
+ const { store, conn, send } = await renderNotice();
498
412
  send({ op: "reconnected" });
499
413
  send({
500
414
  op: "startup-progress",
@@ -512,13 +426,15 @@ describe("connection notice", () => {
512
426
  screen.queryByRole("region", { name: "Notebook startup" }),
513
427
  ).not.toBeInTheDocument();
514
428
 
515
- // A new transport starts from the next session's authoritative snapshot.
516
- await act(async () => options.onOpen(new Event("open")));
429
+ // A new connection starts from the next session's authoritative snapshot.
430
+ await act(async () => conn.drop());
431
+ await act(async () => vi.advanceTimersByTime(2000));
432
+ await act(async () => conn.open());
517
433
  expect(store.get(startupProgressAtom)).toBeNull();
518
434
  });
519
435
 
520
- it("does not flash a notice when startup finishes within the delay", () => {
521
- const { send } = renderNotice();
436
+ it("does not flash a notice when startup finishes within the delay", async () => {
437
+ const { send } = await renderNotice();
522
438
  send({
523
439
  op: "startup-progress",
524
440
  phase: "preparing-environment",
@@ -546,16 +462,11 @@ describe("connection notice", () => {
546
462
  it.each(["preparing-environment", "starting-kernel"] as const)(
547
463
  "keeps a %s failure available until the user retries",
548
464
  async (phase) => {
549
- const { store, transport, options, send } = renderNotice();
465
+ const { store, fake, conn, send } = await renderNotice();
550
466
  send({ op: "startup-progress", phase, logs: "", log_mode: "replace" });
551
467
  const error = "A full diagnostic\nwith <stderr> details";
552
468
  send({ op: "kernel-startup-error", error });
553
- transport.readyState = WebSocket.CLOSED;
554
- act(() =>
555
- options.onClose(
556
- new CloseEvent("close", { reason: "MARIMO_KERNEL_STARTUP_ERROR" }),
557
- ),
558
- );
469
+ await act(async () => conn.close("MARIMO_KERNEL_STARTUP_ERROR"));
559
470
  expect(screen.getByRole("status")).toHaveTextContent(
560
471
  "Kernel failed to start",
561
472
  );
@@ -566,11 +477,11 @@ describe("connection notice", () => {
566
477
  expect(screen.getByLabelText("Error details")).toHaveTextContent(error, {
567
478
  normalizeWhitespace: false,
568
479
  });
569
- expect(transport.reconnect).not.toHaveBeenCalled();
480
+ expect(fake.attempts).toHaveLength(1);
570
481
  await act(async () =>
571
482
  fireEvent.click(screen.getByRole("button", { name: "Try again" })),
572
483
  );
573
- expect(transport.reconnect).toHaveBeenCalledOnce();
484
+ expect(fake.attempts).toHaveLength(2);
574
485
  expect(store.get(kernelStartupErrorAtom)).toBeNull();
575
486
  expect(
576
487
  screen.queryByRole("button", { name: "Try again" }),
@@ -590,23 +501,14 @@ describe("connection notice", () => {
590
501
  );
591
502
  });
592
503
 
593
- it("replaces environment state from snapshots, then continues live progress", () => {
504
+ it("replaces environment state from snapshots, then continues live progress", async () => {
594
505
  const store = createStore();
595
- vi.mocked(createConnectionTransport).mockClear();
596
- vi.mocked(createConnectionTransport).mockReturnValue(
597
- makeTransport(WebSocket.OPEN),
598
- );
506
+ const fake = withTransport();
599
507
  vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
600
508
  renderConnectionHook(store);
601
- const options = getTransportHandlers();
509
+ await started();
602
510
  function receive(data: NotificationPayload["data"]) {
603
- act(() =>
604
- options?.onMessage(
605
- new MessageEvent("message", {
606
- data: JSON.stringify({ op: data.op, data }),
607
- }),
608
- ),
609
- );
511
+ act(() => connection(fake).send(data));
610
512
  }
611
513
  receive({
612
514
  op: "environment-operation",