@marimo-team/frontend 0.25.3-dev2 → 0.25.3-dev20

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 (182) hide show
  1. package/dist/assets/{CellStatus-BP2mavAq.js → CellStatus-DVOHpdeJ.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-t8fH_TNc.js → ConnectedDataExplorerComponent-TprnxZeJ.js} +1 -1
  3. package/dist/assets/{ErrorBoundary-Gl5nCA8C.js → ErrorBoundary-CM13wrd8.js} +2 -2
  4. package/dist/assets/JsonOutput-Cx1iyibB.js +11 -0
  5. package/dist/assets/{LazyAnyLanguageCodeMirror-CKcCbG2G.js → LazyAnyLanguageCodeMirror-B4fCknq1.js} +2 -2
  6. package/dist/assets/MarimoErrorOutput-s_5GOX8k.js +10 -0
  7. package/dist/assets/{RenderHTML-BbOqmHy3.js → RenderHTML-DX6i99Op.js} +1 -1
  8. package/dist/assets/{RunButton-CK3LhnP5.js → RunButton-BKoh0zYG.js} +1 -1
  9. package/dist/assets/{add-cell-with-ai-8UBC0xwR.js → add-cell-with-ai-BpD_790A.js} +11 -11
  10. package/dist/assets/{add-connection-dialog-DXQnd6eB.js → add-connection-dialog-B1BXCjex.js} +2 -2
  11. package/dist/assets/{agent-panel-D7kMZLG-.js → agent-panel-CDFUc1uu.js} +5 -5
  12. package/dist/assets/ai-model-dropdown-i1vfGeVi.js +13 -0
  13. package/dist/assets/{any-language-editor-BLCAD3yx.js → any-language-editor-BdTWdMCI.js} +1 -1
  14. package/dist/assets/{app-config-button-BKEG4x9u.js → app-config-button-AdV-QDD7.js} +1 -1
  15. package/dist/assets/{blob-D5nfNHOo.js → blob-BG8yoQmx.js} +1 -1
  16. package/dist/assets/{cell-editor-ADVfycpN.js → cell-editor-BKJ06H5F.js} +9 -9
  17. package/dist/assets/cell-link-8Auigi8P.js +1 -0
  18. package/dist/assets/{cells-BRkCaSsh.js → cells-C6YGYGdi.js} +56 -56
  19. package/dist/assets/{chat-display-Dnagy4Nv.js → chat-display-DhgI2MS9.js} +1 -1
  20. package/dist/assets/{chat-panel-DQ6HkWEF.js → chat-panel-Cau71Ygr.js} +2 -2
  21. package/dist/assets/{chat-ui-Df8Vcj6n.js → chat-ui-C5ql1Up-.js} +1 -1
  22. package/dist/assets/code-visibility-BJImdpDi.js +1 -0
  23. package/dist/assets/coerce-DWjFeXOY.js +1 -0
  24. package/dist/assets/{column-preview-Cm2D6vyX.js → column-preview-BZ_iNhtJ.js} +1 -1
  25. package/dist/assets/{command-palette-DTSQ7gOC.js → command-palette-DDC3kBet.js} +1 -1
  26. package/dist/assets/{common-Cu6CDbuw.js → common-Sl3uthjg.js} +1 -1
  27. package/dist/assets/{components-CGw16R_N.js → components-3cou6Mka.js} +1 -1
  28. package/dist/assets/{components-CP_UoFtT.js → components-CskjK6xZ.js} +1 -1
  29. package/dist/assets/{components-CXfO-F47.js → components-YBmtV0p6.js} +1 -1
  30. package/dist/assets/{config-VPSq73n8.js → config-CA1V-wjh.js} +1 -1
  31. package/dist/assets/config-DRIpOKqh.js +1 -0
  32. package/dist/assets/config-Dy28fDwn.js +1 -0
  33. package/dist/assets/{datasource-Cq6x_5ML.js → datasource-DfbNb83e.js} +1 -1
  34. package/dist/assets/{dates-BbFQ2oE4.js → dates-CfmdTL6B.js} +1 -1
  35. package/dist/assets/{dependency-graph-panel--Kgtktcw.js → dependency-graph-panel-L5cBE5sl.js} +1 -1
  36. package/dist/assets/{documentation-panel-BHmbeznw.js → documentation-panel-D7S8JIc2.js} +1 -1
  37. package/dist/assets/{download-DCJXy66H.js → download-C03FOElq.js} +1 -1
  38. package/dist/assets/edit-page-CLF_VUTW.js +9 -0
  39. package/dist/assets/{error-banner-Cd69vvyX.js → error-banner-COZ7SfYV.js} +1 -1
  40. package/dist/assets/{error-entries-Dk3VZ-OK.js → error-entries-CLnPDv7b.js} +1 -1
  41. package/dist/assets/{error-panel-BPRYmC5J.js → error-panel-D2xrYLue.js} +1 -1
  42. package/dist/assets/{errors-DCrsa1nH.js → errors-CNsgAw6m.js} +1 -1
  43. package/dist/assets/{feedback-button-Bik2-e33.js → feedback-button-DzPunIqU.js} +2 -2
  44. package/dist/assets/feedback-button-Kla7i5RT.js +1 -0
  45. package/dist/assets/{file-explorer-panel-BEEK5lcl.js → file-explorer-panel-Uk4_QIJQ.js} +1 -1
  46. package/dist/assets/{file-name-input-Cdc-TVqO.js → file-name-input-B4fRaQkI.js} +1 -1
  47. package/dist/assets/{floating-outline-XD9FAV-f.js → floating-outline-B5MaM1JS.js} +1 -1
  48. package/dist/assets/{gallery-page-Dm6mGwBi.js → gallery-page-BefvdsM7.js} +1 -1
  49. package/dist/assets/{glide-data-editor-J9CAb70d.js → glide-data-editor-C3eaYlXg.js} +1 -1
  50. package/dist/assets/{home-page-DMHnsRxm.js → home-page-hmvKlc1w.js} +1 -1
  51. package/dist/assets/{hooks-93IwHd2u.js → hooks-DzML9nez.js} +1 -1
  52. package/dist/assets/index-6b4mf8Ai.css +2 -0
  53. package/dist/assets/index-CMx5iSyu.js +30 -0
  54. package/dist/assets/{layout-DmcNEu3P.js → layout-Ck1RoL1B.js} +5 -5
  55. package/dist/assets/{logs-panel-ABM16htT.js → logs-panel-BRytaWaO.js} +1 -1
  56. package/dist/assets/{markdown-renderer-BG3sp2rs.js → markdown-renderer-DX0Xrrfy.js} +1 -1
  57. package/dist/assets/{mermaid-a3xYymTy.js → mermaid-C4EiVqcd.js} +1 -1
  58. package/dist/assets/{name-cell-input-BgEBmqyL.js → name-cell-input-DlpTvPLa.js} +1 -1
  59. package/dist/assets/{outline-panel-DCXQ3_F6.js → outline-panel-B2WIpz-1.js} +1 -1
  60. package/dist/assets/{packages-panel-BMGOmZkR.js → packages-panel-CI5ff9b-.js} +1 -1
  61. package/dist/assets/{panel-accordion-state-C1sdPp0m.js → panel-accordion-state-BqSH5ttk.js} +1 -1
  62. package/dist/assets/panels-B_jlikjE.js +107 -0
  63. package/dist/assets/{process-output-D0KGq04R.js → process-output-f3C3PD0Z.js} +1 -1
  64. package/dist/assets/{readonly-code-display-Do50PHI8.js → readonly-code-display-MnMEoYW5.js} +1 -1
  65. package/dist/assets/{readonly-python-code-DCSMIsTX.js → readonly-python-code-ggs4hAzC.js} +1 -1
  66. package/dist/assets/{renderShortcut-BkVJQ_Zh.js → renderShortcut-C1JwYcqW.js} +1 -1
  67. package/dist/assets/{reveal-component-ltaepAqw.js → reveal-component-PiV1eQk9.js} +1 -1
  68. package/dist/assets/run-page-CkP22x9D.js +1 -0
  69. package/dist/assets/{runtime-details-state-CSPobEdR.js → runtime-details-state-CoL-p0MC.js} +1 -1
  70. package/dist/assets/{schemas-Dv42wLFq.js → schemas-ChRqMOTL.js} +1 -1
  71. package/dist/assets/{scratchpad-panel-Dwd6W5PE.js → scratchpad-panel-D2rWg4k8.js} +1 -1
  72. package/dist/assets/{secrets-panel-Bctbtkrp.js → secrets-panel-D7KN4tVs.js} +1 -1
  73. package/dist/assets/session-panel-CnANhcTo.js +1 -0
  74. package/dist/assets/{snippets-panel-Bh1oTw4m.js → snippets-panel-ClGHtEKQ.js} +1 -1
  75. package/dist/assets/state-CbjBtkdd.js +3 -0
  76. package/dist/assets/{state-XsPO8hNi.js → state-DtGB7xeJ.js} +1 -1
  77. package/dist/assets/{terminal-Ck_vK-34.js → terminal-wnSHYT8N.js} +1 -1
  78. package/dist/assets/{tracing-Di6pUmX8.js → tracing-Dz2rYOFL.js} +1 -1
  79. package/dist/assets/{tracing-panel-PeRXHD8_.js → tracing-panel-DqQY9V91.js} +2 -2
  80. package/dist/assets/{tree-actions-GM5E_bUs.js → tree-actions-DnUokmva.js} +1 -1
  81. package/dist/assets/{useAddCell-GR7eEQrT.js → useAddCell-IN24thff.js} +1 -1
  82. package/dist/assets/{useBoolean-BDD6_xQQ.js → useBoolean-d0Tty94N.js} +1 -1
  83. package/dist/assets/{useCellActionButton-B-9eG5z_.js → useCellActionButton-DmaFZ4oF.js} +1 -1
  84. package/dist/assets/{useDeleteCell-2_bMClHd.js → useDeleteCell-fQlM46Y2.js} +1 -1
  85. package/dist/assets/{useDependencyPanelTab-k3C3M18S.js → useDependencyPanelTab-ImXYINm_.js} +1 -1
  86. package/dist/assets/{useInstallPackage-iX1eufdl.js → useInstallPackage-BW06eter.js} +1 -1
  87. package/dist/assets/{useNotebookActions-NHN6A-cN.js → useNotebookActions-DHt3s-ia.js} +1 -1
  88. package/dist/assets/{useRunCells-B2vUhREg.js → useRunCells-DSJPXE-j.js} +1 -1
  89. package/dist/assets/{useSplitCell-DEb03htp.js → useSplitCell-DQyMF5gd.js} +1 -1
  90. package/dist/assets/{useTheme-COhSHkCj.js → useTheme-n8Dj1zH7.js} +1 -1
  91. package/dist/assets/{vega-component-CnadTgoE.js → vega-component-IouMe13t.js} +1 -1
  92. package/dist/index.html +34 -35
  93. package/package.json +1 -1
  94. package/src/__tests__/fixtures/{pair-preview.ts → pair-config.ts} +3 -2
  95. package/src/__tests__/mount.test.ts +28 -8
  96. package/src/components/data-table/__tests__/context-menu.test.ts +176 -0
  97. package/src/components/data-table/__tests__/date-filter-inputs.test.tsx +24 -1
  98. package/src/components/data-table/__tests__/export-actions.test.tsx +329 -6
  99. package/src/components/data-table/context-menu.tsx +125 -26
  100. package/src/components/data-table/date-filter-inputs.tsx +15 -42
  101. package/src/components/data-table/export-actions.tsx +192 -116
  102. package/src/components/data-table/schemas.ts +14 -36
  103. package/src/components/data-table/utils.ts +18 -0
  104. package/src/components/editor/Output.tsx +9 -2
  105. package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +80 -252
  106. package/src/components/editor/actions/__tests__/pair-with-agent-modal.test.tsx +186 -34
  107. package/src/components/editor/actions/pair-with-agent-commands.ts +42 -51
  108. package/src/components/editor/actions/pair-with-agent-modal.tsx +96 -36
  109. package/src/components/editor/chrome/panels/error-panel.tsx +1 -0
  110. package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +372 -0
  111. package/src/components/editor/chrome/wrapper/footer-item.tsx +32 -24
  112. package/src/components/editor/chrome/wrapper/footer-items/attachment-status.tsx +237 -0
  113. package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
  114. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +548 -0
  115. package/src/components/editor/errors/send-error-report-button.tsx +69 -0
  116. package/src/components/editor/output/MarimoErrorOutput.tsx +21 -5
  117. package/src/components/editor/output/MarimoTracebackOutput.tsx +35 -20
  118. package/src/components/editor/output/__tests__/ansi-reduce.test.ts +18 -0
  119. package/src/components/editor/output/ansi-reduce.ts +6 -0
  120. package/src/core/attachments/__tests__/toast.test.tsx +58 -0
  121. package/src/core/attachments/state.ts +21 -0
  122. package/src/core/attachments/toast.ts +15 -0
  123. package/src/core/cells/__tests__/cells.test.ts +52 -0
  124. package/src/core/cells/__tests__/error-report.test.ts +139 -0
  125. package/src/core/cells/__tests__/session.test.ts +50 -1
  126. package/src/core/cells/error-report.ts +63 -0
  127. package/src/core/cells/session.ts +28 -1
  128. package/src/core/config/pair.ts +5 -3
  129. package/src/core/edit-app.tsx +2 -2
  130. package/src/core/host/AGENTS.md +5 -0
  131. package/src/core/host/__tests__/document.test.ts +124 -0
  132. package/src/core/host/__tests__/events.test.ts +46 -0
  133. package/src/core/host/__tests__/fixtures.ts +39 -0
  134. package/src/core/host/__tests__/notebook-client.test.ts +303 -0
  135. package/src/core/host/__tests__/stream.test.ts +211 -0
  136. package/src/core/host/connection.ts +81 -0
  137. package/src/core/host/events.ts +60 -0
  138. package/src/core/host/notebook-client.ts +272 -0
  139. package/src/core/host/resources/document.ts +101 -0
  140. package/src/core/host/resources/resource.ts +59 -0
  141. package/src/core/host/stream.ts +213 -0
  142. package/src/core/islands/__tests__/bootstrap.test.ts +26 -0
  143. package/src/core/islands/bootstrap.ts +1 -0
  144. package/src/core/network/connection.ts +4 -2
  145. package/src/core/run-app.tsx +2 -2
  146. package/src/core/runtime/__tests__/runtime.test.ts +55 -11
  147. package/src/core/runtime/runtime.ts +14 -1
  148. package/src/core/runtime/types.ts +2 -0
  149. package/src/core/websocket/__tests__/{useMarimoKernelConnection.test.ts → connection-state.test.ts} +15 -17
  150. package/src/core/websocket/__tests__/{useWebSocket.test.ts → createConnectionTransport.test.ts} +1 -1
  151. package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +140 -0
  152. package/src/core/websocket/__tests__/{useMarimoKernelConnection.hook.test.tsx → useNotebookSession.hook.test.tsx} +352 -65
  153. package/src/core/websocket/connection-state.ts +70 -0
  154. package/src/core/websocket/transports/__tests__/sse.test.ts +1 -4
  155. package/src/core/websocket/transports/create.ts +31 -0
  156. package/src/core/websocket/transports/transport.ts +12 -23
  157. package/src/core/websocket/types.ts +9 -39
  158. package/src/core/websocket/useNotebookClient.ts +80 -0
  159. package/src/core/websocket/{useMarimoKernelConnection.tsx → useNotebookSession.tsx} +67 -225
  160. package/src/mount.tsx +7 -3
  161. package/src/plugins/impl/__tests__/DataTablePlugin.test.tsx +84 -0
  162. package/src/utils/__tests__/dates.test.ts +116 -0
  163. package/src/utils/dates.ts +94 -0
  164. package/dist/assets/JsonOutput-D6HIIsCb.js +0 -11
  165. package/dist/assets/MarimoErrorOutput-D60XkCny.js +0 -6
  166. package/dist/assets/ai-model-dropdown-B0z6-zhp.js +0 -5
  167. package/dist/assets/api-BeC_VSGv.js +0 -1
  168. package/dist/assets/cell-link-LSth_8hT.js +0 -1
  169. package/dist/assets/code-visibility-B-_GvwR5.js +0 -1
  170. package/dist/assets/coerce-CpE6kUdX.js +0 -1
  171. package/dist/assets/config-BOKzA3tF.js +0 -1
  172. package/dist/assets/config-DX8Y2WbJ.js +0 -1
  173. package/dist/assets/edit-page-ChcSgpke.js +0 -9
  174. package/dist/assets/feedback-button-B2gKC5cn.js +0 -1
  175. package/dist/assets/index-Bcdzex-3.css +0 -2
  176. package/dist/assets/index-s-M1OXjs.js +0 -29
  177. package/dist/assets/pair-CaQyK6En.js +0 -1
  178. package/dist/assets/panels-CxVG8-v2.js +0 -4
  179. package/dist/assets/run-page-DF3c5cDk.js +0 -1
  180. package/dist/assets/session-panel-VdDhEBa-.js +0 -1
  181. package/dist/assets/state-_Qai_QyX.js +0 -3
  182. package/src/core/websocket/useWebSocket.tsx +0 -88
@@ -41,7 +41,7 @@ import { viewStateAtom } from "./mode";
41
41
  import { useRequestClient } from "./network/requests";
42
42
  import { useFilename } from "./saving/filename";
43
43
  import { lastSavedNotebookAtom } from "./saving/state";
44
- import { useMarimoKernelConnection } from "./websocket/useMarimoKernelConnection";
44
+ import { useNotebookSession } from "./websocket/useNotebookSession";
45
45
 
46
46
  interface AppProps {
47
47
  /**
@@ -83,7 +83,7 @@ export const EditApp: React.FC<AppProps> = ({
83
83
  };
84
84
  }, []);
85
85
 
86
- const { connection, reconnect } = useMarimoKernelConnection({
86
+ const { connection, reconnect } = useNotebookSession({
87
87
  autoInstantiate: userConfig.runtime.auto_instantiate,
88
88
  setCells: (cells, layout) => {
89
89
  setCells(cells);
@@ -4,3 +4,8 @@ Production code may import host modules, `jotai/vanilla`, `zod`, and
4
4
  `@/utils/Logger`. Enforced by `marimo/host-imports`. Tests are exempt.
5
5
 
6
6
  Define interfaces here and inject application behavior through adapters.
7
+
8
+ `stream.ts` reduces stream events into one state. Each kind of object is a
9
+ `Resource` in `resources/` with pure handlers keyed by event name. The stream
10
+ handles phase, notebook identity, and control events itself. The client holds
11
+ one state atom and exposes derived atoms.
@@ -0,0 +1,124 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import type { NotebookEvent } from "../events";
5
+ import { document } from "../resources/document";
6
+ import { createStream } from "../stream";
7
+ import { fixtureEvents } from "./fixtures";
8
+
9
+ const stream = createStream({ document });
10
+ const snapshot = fixtureEvents("notebook-events.sse");
11
+ const edits = fixtureEvents("edits.sse");
12
+
13
+ function replay(events: readonly NotebookEvent[], from = stream.initial) {
14
+ return events.reduce((state, event) => stream.reduce(state, event), from);
15
+ }
16
+
17
+ const confirmed = () => replay(snapshot);
18
+ const current = (state: ReturnType<typeof replay>) =>
19
+ state.resources.document.current;
20
+
21
+ describe("document resource", () => {
22
+ it("publishes the snapshot at ready, then each completed edit and save", () => {
23
+ const ready = snapshot.findIndex((event) => event.event === "ready");
24
+ const opening = replay(snapshot.slice(0, ready));
25
+ expect(current(opening)).toBeNull();
26
+ const first = current(replay(snapshot.slice(ready), opening));
27
+ expect(first?.revision.order).toEqual(["c1", "c3", "k7f2"]);
28
+ expect([...first!.cells.keys()]).toEqual(first?.revision.order);
29
+
30
+ const [edit, save] = edits.filter((event) => event.event === "document");
31
+ const pending = replay(
32
+ edits.filter((event) => event.event !== "document"),
33
+ confirmed(),
34
+ );
35
+ expect(current(pending)).toEqual(first);
36
+ const changed = stream.reduce(pending, edit);
37
+ expect(current(changed)?.revision).toEqual(edit.data.document);
38
+ expect([...current(changed)!.cells.keys()].toSorted()).toEqual(
39
+ edit.data.document.order.toSorted(),
40
+ );
41
+ const saved = current(stream.reduce(changed, save));
42
+ expect(saved?.revision).toEqual(save.data.document);
43
+ expect(saved?.cells.get("c3")?.code).toBe("x = 22");
44
+ expect(saved?.cells.has("k7f2")).toBe(false);
45
+ expect(saved?.revision.saved_etag).toBe(saved?.revision.etag);
46
+ expect(first?.cells.get("c3")?.code).toBe("x = 21");
47
+ expect(first?.cells.has("k7f2")).toBe(true);
48
+ });
49
+
50
+ it("applies order-only changes and distinguishes an empty notebook from no document", () => {
51
+ const state = confirmed();
52
+ const revision = current(state)!.revision;
53
+ const reordered = {
54
+ ...revision,
55
+ order: revision.order.toReversed(),
56
+ version: revision.version + 1,
57
+ };
58
+ const changed = stream.reduce(state, {
59
+ event: "document",
60
+ data: { document: reordered },
61
+ id: "200",
62
+ });
63
+ expect(current(changed)).toEqual({
64
+ revision: reordered,
65
+ cells: current(state)?.cells,
66
+ });
67
+
68
+ const empty = { ...revision, order: [] };
69
+ const opening = stream.reduce(stream.initial, {
70
+ event: "document",
71
+ data: { document: empty },
72
+ });
73
+ expect(current(opening)).toBeNull();
74
+ expect(
75
+ current(stream.reduce(opening, { event: "ready", data: {}, id: "0" })),
76
+ ).toEqual({ revision: empty, cells: new Map() });
77
+ expect(
78
+ current(
79
+ stream.reduce(stream.initial, { event: "ready", data: {}, id: "0" }),
80
+ ),
81
+ ).toBeNull();
82
+ });
83
+
84
+ it("keeps the document while a live update is pending and after a replay completes it", () => {
85
+ const state = confirmed();
86
+ const [cell, , removed, published] = edits;
87
+ if (published.event !== "document") {
88
+ throw new Error("Fixture does not publish a document");
89
+ }
90
+ const partial = replay([cell, removed], state);
91
+ expect(current(partial)).toBe(current(state));
92
+ expect(stream.resume(partial)).toBe(partial);
93
+ const completed = replay(
94
+ [published, { event: "ready", data: {}, id: "113" }],
95
+ partial,
96
+ );
97
+ expect(current(completed)?.revision).toEqual(published.data.document);
98
+ });
99
+
100
+ it("removes a notebook without waiting for individual cell removals", () => {
101
+ const removed = stream.reduce(confirmed(), {
102
+ event: "notebook.removed",
103
+ data: { id: "n1", time: "2026-10-01T09:30:00Z" },
104
+ id: "200",
105
+ });
106
+ expect(current(removed)).toBeNull();
107
+ });
108
+
109
+ it("rejects records that cannot form a complete revision", () => {
110
+ const state = confirmed();
111
+ const revision = current(state)!.revision;
112
+ expect(() =>
113
+ stream.reduce(state, {
114
+ event: "document",
115
+ data: { document: { ...revision, order: ["missing"] } },
116
+ id: "200",
117
+ }),
118
+ ).toThrow("Document records do not match");
119
+ const partial = stream.reduce(state, edits[0]);
120
+ expect(() =>
121
+ stream.reduce(partial, { event: "ready", data: {}, id: "200" }),
122
+ ).toThrow("incomplete document update");
123
+ });
124
+ });
@@ -0,0 +1,46 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { z } from "zod";
5
+ import { parseNotebookEvent } from "../events";
6
+
7
+ describe("notebook messages", () => {
8
+ it("normalizes session and host envelopes for native marimo messages", () => {
9
+ const notification = {
10
+ op: "variables",
11
+ variables: [{ name: "x", value: Number.NaN }],
12
+ };
13
+ const host = { event: "marimo", data: { marimo: notification } };
14
+ expect(parseNotebookEvent({ data: notification })).toEqual(host);
15
+ expect(parseNotebookEvent({ ...host, id: "42" })).toEqual({
16
+ ...host,
17
+ id: "42",
18
+ });
19
+ });
20
+
21
+ it("retains unknown event cursors without interpreting their payloads", () => {
22
+ expect(
23
+ parseNotebookEvent({ event: "future", id: "42", data: null }),
24
+ ).toEqual({ event: "unknown", name: "future", id: "42" });
25
+ });
26
+
27
+ it("decodes known events outside the client's subscription filters", () => {
28
+ const event = {
29
+ event: "run.removed",
30
+ id: "42",
31
+ data: { id: "r1", time: "2026-10-19T12:00:00Z" },
32
+ };
33
+ expect(parseNotebookEvent(event)).toEqual(event);
34
+ });
35
+
36
+ it.each([
37
+ { event: "cell", id: "42", data: { cell: { id: "c1" } } },
38
+ { event: "run.removed", id: "42", data: {} },
39
+ { event: "ready", data: {} },
40
+ { event: "marimo", data: {} },
41
+ {},
42
+ null,
43
+ ])("rejects malformed known messages: %j", (message) => {
44
+ expect(() => parseNotebookEvent(message)).toThrow(z.ZodError);
45
+ });
46
+ });
@@ -0,0 +1,39 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import NodeFs from "node:fs";
4
+ import NodeUrl from "node:url";
5
+ import { parseNotebookEvent, type NotebookEvent } from "../events";
6
+
7
+ /** Read the protocol's portable examples as transport messages. */
8
+ export function fixtureMessages(name: string) {
9
+ const path = new NodeUrl.URL(
10
+ `../../../../../protocols/host/fixtures/${name}`,
11
+ import.meta.url,
12
+ );
13
+ return NodeFs.readFileSync(path, "utf8")
14
+ .split(/\r?\n\r?\n/)
15
+ .flatMap((frame) => {
16
+ const event = /^event: (.+)$/m.exec(frame)?.[1];
17
+ if (!event) {
18
+ return [];
19
+ }
20
+ const data = frame
21
+ .split(/\r?\n/)
22
+ .filter((line) => line.startsWith("data:"))
23
+ .map((line) => line.slice(5).trimStart())
24
+ .join("\n");
25
+ const payload: unknown = JSON.parse(data);
26
+ return [
27
+ {
28
+ event,
29
+ id: /^id: (.+)$/m.exec(frame)?.[1],
30
+ data: payload,
31
+ },
32
+ ];
33
+ });
34
+ }
35
+
36
+ /** Read the protocol's portable examples as decoded notebook events. */
37
+ export function fixtureEvents(name: string): NotebookEvent[] {
38
+ return fixtureMessages(name).map(parseNotebookEvent);
39
+ }
@@ -0,0 +1,303 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ // @vitest-environment jsdom
3
+
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { createStore } from "jotai";
6
+ import { ConnectionState } from "../../websocket/types";
7
+ import { Deferred } from "@/utils/Deferred";
8
+ import { jsonParseWithSpecialChar } from "@/utils/json/json-parser";
9
+ import { classifyCloseEvent } from "../../websocket/connection-state";
10
+ import { createNotebookClient } from "../notebook-client";
11
+ import { fixtureMessages } from "./fixtures";
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 } });
23
+ }
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
+ };
40
+ const onNotification = vi.fn();
41
+ const checkHealth = vi.fn(async () => true);
42
+ const store = createStore();
43
+ const controller = createNotebookClient(store);
44
+ const client = controller.client;
45
+ const connect = (signal: AbortSignal) =>
46
+ controller.connect({
47
+ createTransport: (next) => {
48
+ provider = next;
49
+ return transport;
50
+ },
51
+ url,
52
+ messageFormat: "host",
53
+ signal,
54
+ waitUntilReady: () => healthy.promise,
55
+ checkHealth,
56
+ onNotification: (notification) => onNotification({ data: notification }),
57
+ onMessageError: vi.fn(),
58
+ decodeJson: jsonParseWithSpecialChar,
59
+ classifyClose: classifyCloseEvent,
60
+ });
61
+ return {
62
+ events,
63
+ healthy,
64
+ transport,
65
+ client,
66
+ connect,
67
+ onNotification,
68
+ store,
69
+ checkHealth,
70
+ urls,
71
+ receive: (message: unknown) =>
72
+ events.dispatchEvent(
73
+ new MessageEvent("message", { data: JSON.stringify(message) }),
74
+ ),
75
+ };
76
+ }
77
+
78
+ describe("NotebookClient lifetime", () => {
79
+ it.each(["{broken", JSON.stringify({ event: "ready", data: {} })])(
80
+ "stops consuming a malformed host stream: %s",
81
+ (data) => {
82
+ const { connect, events, client, store, transport, onNotification } =
83
+ setup();
84
+ const lifetime = new AbortController();
85
+ connect(lifetime.signal);
86
+ events.dispatchEvent(new Event("open"));
87
+ events.dispatchEvent(new MessageEvent("message", { data }));
88
+ events.dispatchEvent(
89
+ new MessageEvent("message", {
90
+ data: JSON.stringify({
91
+ event: "marimo",
92
+ data: { marimo: { op: "reconnected" } },
93
+ }),
94
+ }),
95
+ );
96
+ expect(store.get(client.connectionAtom)).toEqual({
97
+ state: ConnectionState.CLOSED,
98
+ code: "KERNEL_DISCONNECTED",
99
+ reason: "Failed to decode notebook message",
100
+ });
101
+ expect(transport.close).toHaveBeenCalledOnce();
102
+ expect(onNotification).not.toHaveBeenCalled();
103
+ lifetime.abort();
104
+ },
105
+ );
106
+
107
+ it("resumes from the last cursor until reset or a new session", async () => {
108
+ const session = { id: "first", token: "a" };
109
+ const { connect, events, healthy, urls, receive } = setup(
110
+ () =>
111
+ `ws://localhost/ws?session_id=${session.id}&access_token=${session.token}&cursor=999`,
112
+ );
113
+ const lifetime = new AbortController();
114
+ connect(lifetime.signal);
115
+ healthy.resolve();
116
+ await healthy.promise;
117
+ expect(urls[0].searchParams.get("cursor")).toBeNull();
118
+
119
+ const snapshot = fixtureMessages("notebook-events.sse");
120
+ events.dispatchEvent(new Event("open"));
121
+ for (const message of snapshot) {
122
+ receive(message);
123
+ }
124
+ session.token = "b";
125
+ events.dispatchEvent(new CloseEvent("close"));
126
+ expect(urls[1].searchParams.get("cursor")).toBe("97");
127
+ expect(urls[1].searchParams.get("access_token")).toBe("b");
128
+
129
+ events.dispatchEvent(new Event("open"));
130
+ receive({ event: "reset", data: {} });
131
+ events.dispatchEvent(new CloseEvent("close"));
132
+ expect(urls[2].searchParams.get("cursor")).toBeNull();
133
+
134
+ events.dispatchEvent(new Event("open"));
135
+ for (const message of snapshot) {
136
+ receive(message);
137
+ }
138
+ session.id = "second";
139
+ events.dispatchEvent(new CloseEvent("close"));
140
+ expect(urls[3].searchParams.get("cursor")).toBeNull();
141
+ expect(urls[3].searchParams.get("session_id")).toBe("second");
142
+ lifetime.abort();
143
+ });
144
+
145
+ it("exposes the confirmed document until the lifetime ends", async () => {
146
+ const { connect, events, client, store, healthy, receive } = setup();
147
+ const lifetime = new AbortController();
148
+ connect(lifetime.signal);
149
+ healthy.resolve();
150
+ await healthy.promise;
151
+ events.dispatchEvent(new Event("open"));
152
+ const snapshot = fixtureMessages("notebook-events.sse");
153
+ for (const message of snapshot.slice(0, -3)) {
154
+ receive(message);
155
+ }
156
+ expect(store.get(client.documentAtom)).toBeNull();
157
+ for (const message of snapshot.slice(-3)) {
158
+ receive(message);
159
+ }
160
+ expect(store.get(client.documentAtom)?.revision.order).toEqual([
161
+ "c1",
162
+ "c3",
163
+ "k7f2",
164
+ ]);
165
+ events.dispatchEvent(new CloseEvent("close"));
166
+ expect(store.get(client.documentAtom)?.revision.order).toEqual([
167
+ "c1",
168
+ "c3",
169
+ "k7f2",
170
+ ]);
171
+ lifetime.abort();
172
+ expect(store.get(client.documentAtom)).toBeNull();
173
+ });
174
+
175
+ it("stops consuming a stream whose notebook changes", () => {
176
+ const { connect, events, client, store, transport } = setup();
177
+ const lifetime = new AbortController();
178
+ connect(lifetime.signal);
179
+ events.dispatchEvent(new Event("open"));
180
+ for (const id of ["n1", "n2"]) {
181
+ events.dispatchEvent(
182
+ new MessageEvent("message", { data: notebookMessage(id) }),
183
+ );
184
+ }
185
+ expect(store.get(client.connectionAtom)).toEqual({
186
+ state: ConnectionState.CLOSED,
187
+ code: "KERNEL_DISCONNECTED",
188
+ reason: "Failed to decode notebook message",
189
+ });
190
+ expect(transport.close).toHaveBeenCalledOnce();
191
+ lifetime.abort();
192
+ });
193
+
194
+ it.each(["MARIMO_UNAUTHORIZED", "MARIMO_TRANSPORT_EXHAUSTED"])(
195
+ "stops automatic recovery after %s but permits an explicit reconnect",
196
+ async (reason) => {
197
+ const {
198
+ connect,
199
+ client,
200
+ store,
201
+ events,
202
+ transport,
203
+ healthy,
204
+ checkHealth,
205
+ } = setup();
206
+ const lifetime = new AbortController();
207
+ connect(lifetime.signal);
208
+ healthy.resolve();
209
+ await healthy.promise;
210
+ events.dispatchEvent(new CloseEvent("close", { reason }));
211
+ events.dispatchEvent(new Event("error"));
212
+ expect(store.get(client.connectionAtom).state).toBe(
213
+ ConnectionState.CLOSED,
214
+ );
215
+ expect(transport.reconnect).toHaveBeenCalledOnce();
216
+ await client.reconnect();
217
+ expect(checkHealth).toHaveBeenCalledOnce();
218
+ expect(transport.reconnect).toHaveBeenCalledTimes(2);
219
+ lifetime.abort();
220
+ },
221
+ );
222
+
223
+ it("only resets the automatic retry budget after an open event", () => {
224
+ const { connect, events, transport } = setup();
225
+ const lifetime = new AbortController();
226
+ connect(lifetime.signal);
227
+ events.dispatchEvent(new CloseEvent("close"));
228
+ events.dispatchEvent(new Event("error"));
229
+ events.dispatchEvent(new CloseEvent("close"));
230
+ expect(transport.reconnect).toHaveBeenCalledOnce();
231
+ events.dispatchEvent(new Event("open"));
232
+ events.dispatchEvent(new CloseEvent("close"));
233
+ expect(transport.reconnect).toHaveBeenCalledTimes(2);
234
+ lifetime.abort();
235
+ });
236
+
237
+ it("does not revive an aborted lifetime when a manual health check finishes", async () => {
238
+ const { connect, client, transport, checkHealth } = setup();
239
+ const health = new Deferred<boolean>();
240
+ checkHealth.mockReturnValue(health.promise);
241
+ const lifetime = new AbortController();
242
+ connect(lifetime.signal);
243
+ const attempt = client.reconnect();
244
+ lifetime.abort();
245
+ health.resolve(true);
246
+ await attempt;
247
+ expect(transport.reconnect).not.toHaveBeenCalled();
248
+ });
249
+
250
+ it("does not connect after abort, even when the health check finishes", async () => {
251
+ const { client, connect, healthy, transport } = setup();
252
+ const controller = new AbortController();
253
+ connect(controller.signal);
254
+ controller.abort();
255
+ healthy.resolve();
256
+ await healthy.promise;
257
+ client.reconnect();
258
+ expect(transport.reconnect).not.toHaveBeenCalled();
259
+ expect(transport.close).toHaveBeenCalledOnce();
260
+ });
261
+
262
+ it("detaches listeners before closing so cleanup cannot trigger a session retry", () => {
263
+ const { connect, client, store, events, transport, onNotification } =
264
+ setup();
265
+ const controller = new AbortController();
266
+ connect(controller.signal);
267
+ controller.abort();
268
+ events.dispatchEvent(
269
+ new MessageEvent("message", {
270
+ data: JSON.stringify({ data: { op: "reconnected" } }),
271
+ }),
272
+ );
273
+ expect(transport.reconnect).not.toHaveBeenCalled();
274
+ expect(store.get(client.connectionAtom)).toEqual({
275
+ state: ConnectionState.NOT_STARTED,
276
+ });
277
+ expect(onNotification).not.toHaveBeenCalled();
278
+ });
279
+
280
+ it("can reconnect after effect cleanup without reviving the earlier client attempt", async () => {
281
+ const { connect, healthy, transport, events, onNotification } = setup();
282
+ const first = new AbortController();
283
+ connect(first.signal);
284
+ first.abort();
285
+ const second = new AbortController();
286
+ connect(second.signal);
287
+ healthy.resolve();
288
+ await healthy.promise;
289
+ expect(transport.reconnect).toHaveBeenCalledOnce();
290
+ events.dispatchEvent(
291
+ new MessageEvent("message", {
292
+ data: JSON.stringify({
293
+ event: "marimo",
294
+ data: { marimo: { op: "reconnected" } },
295
+ }),
296
+ }),
297
+ );
298
+ expect(onNotification).toHaveBeenCalledExactlyOnceWith({
299
+ data: { op: "reconnected" },
300
+ });
301
+ second.abort();
302
+ });
303
+ });