@marimo-team/frontend 0.25.3-dev3 → 0.25.3-dev31
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.
- package/dist/assets/{CellStatus-BP2mavAq.js → CellStatus-wFCGHpDX.js} +1 -1
- package/dist/assets/{ConnectedDataExplorerComponent-t8fH_TNc.js → ConnectedDataExplorerComponent-DMdJT25t.js} +1 -1
- package/dist/assets/{ErrorBoundary-Gl5nCA8C.js → ErrorBoundary-D1ozl9e0.js} +2 -2
- package/dist/assets/JsonOutput-Dqg7qwkl.js +11 -0
- package/dist/assets/{LazyAnyLanguageCodeMirror-CKcCbG2G.js → LazyAnyLanguageCodeMirror-B4fCknq1.js} +2 -2
- package/dist/assets/MarimoErrorOutput-BYSTEwo1.js +10 -0
- package/dist/assets/{RenderHTML-BbOqmHy3.js → RenderHTML-B18UiW8r.js} +1 -1
- package/dist/assets/{RunButton-CK3LhnP5.js → RunButton-CWvRnb98.js} +1 -1
- package/dist/assets/{add-cell-with-ai-8UBC0xwR.js → add-cell-with-ai-C4pN_ho7.js} +13 -13
- package/dist/assets/{add-connection-dialog-DXQnd6eB.js → add-connection-dialog-BErVOfUV.js} +2 -2
- package/dist/assets/{agent-panel-D7kMZLG-.js → agent-panel-QQDTP8Vb.js} +5 -5
- package/dist/assets/ai-model-dropdown-D5Fe5GS-.js +13 -0
- package/dist/assets/{any-language-editor-BLCAD3yx.js → any-language-editor-BdTWdMCI.js} +1 -1
- package/dist/assets/{app-config-button-BKEG4x9u.js → app-config-button-DBHN-U2l.js} +1 -1
- package/dist/assets/{blob-D5nfNHOo.js → blob-DY_W1wFZ.js} +1 -1
- package/dist/assets/{cell-editor-ADVfycpN.js → cell-editor-CCFDuQIT.js} +9 -9
- package/dist/assets/{cell-link-LSth_8hT.js → cell-link-BOqoitvE.js} +1 -1
- package/dist/assets/{cells-BRkCaSsh.js → cells-CN1I_0eH.js} +31 -31
- package/dist/assets/{chat-display-Dnagy4Nv.js → chat-display-D1_UV6v7.js} +1 -1
- package/dist/assets/{chat-panel-DQ6HkWEF.js → chat-panel-C4OJDIt6.js} +2 -2
- package/dist/assets/{chat-ui-Df8Vcj6n.js → chat-ui-wuDYPZ-w.js} +1 -1
- package/dist/assets/code-visibility-CnU9xZw7.js +1 -0
- package/dist/assets/coerce-DWjFeXOY.js +1 -0
- package/dist/assets/{column-preview-Cm2D6vyX.js → column-preview-z4bqk4HW.js} +1 -1
- package/dist/assets/command-palette-TblDi15Q.js +1 -0
- package/dist/assets/{common-Cu6CDbuw.js → common-Cbk0KIh7.js} +1 -1
- package/dist/assets/{components-CGw16R_N.js → components-C88UxBXz.js} +1 -1
- package/dist/assets/{components-CP_UoFtT.js → components-CskjK6xZ.js} +1 -1
- package/dist/assets/{components-CXfO-F47.js → components-YBmtV0p6.js} +1 -1
- package/dist/assets/{config-VPSq73n8.js → config-CA1V-wjh.js} +1 -1
- package/dist/assets/config-DEzFV5-l.js +1 -0
- package/dist/assets/config-MaHeMC9o.js +104 -0
- package/dist/assets/{datasource-Cq6x_5ML.js → datasource-DZJ6qqVI.js} +1 -1
- package/dist/assets/{dates-BbFQ2oE4.js → dates-CfmdTL6B.js} +1 -1
- package/dist/assets/{dependency-graph-panel--Kgtktcw.js → dependency-graph-panel-DeZ66gIo.js} +1 -1
- package/dist/assets/{documentation-panel-BHmbeznw.js → documentation-panel-Tl5G1cPt.js} +1 -1
- package/dist/assets/{download-DCJXy66H.js → download-s8iwxGDb.js} +1 -1
- package/dist/assets/edit-page-CjoAJvyN.js +9 -0
- package/dist/assets/{error-banner-Cd69vvyX.js → error-banner-COZ7SfYV.js} +1 -1
- package/dist/assets/{error-entries-Dk3VZ-OK.js → error-entries-D11Bn1Hk.js} +1 -1
- package/dist/assets/{error-panel-BPRYmC5J.js → error-panel-kfxFDscu.js} +1 -1
- package/dist/assets/{errors-DCrsa1nH.js → errors-CNsgAw6m.js} +1 -1
- package/dist/assets/feedback-button-3bZHpuSh.js +1 -0
- package/dist/assets/{feedback-button-Bik2-e33.js → feedback-button-DVABjD9Y.js} +2 -2
- package/dist/assets/{file-explorer-panel-BEEK5lcl.js → file-explorer-panel-CvmK-rEU.js} +1 -1
- package/dist/assets/{file-name-input-Cdc-TVqO.js → file-name-input-DAj9a3RQ.js} +1 -1
- package/dist/assets/{floating-outline-XD9FAV-f.js → floating-outline-DDavqWJx.js} +1 -1
- package/dist/assets/{formats-yZ06h1Pl.js → formats-BkelUASr.js} +1 -1
- package/dist/assets/{gallery-page-Dm6mGwBi.js → gallery-page-xP6VqDyd.js} +1 -1
- package/dist/assets/{ganttDiagram-EL5Y4UJY-2n7Ig5Jm.js → ganttDiagram-EL5Y4UJY-CYqkCfFG.js} +1 -1
- package/dist/assets/{glide-data-editor-J9CAb70d.js → glide-data-editor-32vBJJf8.js} +1 -1
- package/dist/assets/{home-page-DMHnsRxm.js → home-page-CvGzjVr4.js} +1 -1
- package/dist/assets/hooks-C1_gQ8kr.js +1 -0
- package/dist/assets/index-6b4mf8Ai.css +2 -0
- package/dist/assets/index-Dtv0HH-7.js +30 -0
- package/dist/assets/{layout-DmcNEu3P.js → layout-BccvBeZy.js} +5 -5
- package/dist/assets/linear-Bma8J3iM.js +1 -0
- package/dist/assets/{logs-panel-ABM16htT.js → logs-panel-BhXo1sVJ.js} +1 -1
- package/dist/assets/{markdown-renderer-BG3sp2rs.js → markdown-renderer-D2dafr8r.js} +1 -1
- package/dist/assets/{mermaid-a3xYymTy.js → mermaid-D2FUGQeW.js} +2 -2
- package/dist/assets/{name-cell-input-BgEBmqyL.js → name-cell-input-SHvgfcBR.js} +1 -1
- package/dist/assets/{outline-panel-DCXQ3_F6.js → outline-panel-CpY3FH-u.js} +1 -1
- package/dist/assets/{packages-panel-BMGOmZkR.js → packages-panel-oZ0qZBec.js} +1 -1
- package/dist/assets/{panel-accordion-state-C1sdPp0m.js → panel-accordion-state-ChFIY2WQ.js} +1 -1
- package/dist/assets/panels-91ywfwgc.js +4 -0
- package/dist/assets/precisionRound-BxlUs8G4.js +1 -0
- package/dist/assets/{process-output-D0KGq04R.js → process-output-Dvq4b2kJ.js} +1 -1
- package/dist/assets/{quadrantDiagram-AXDQQJYC-yBhCDI7E.js → quadrantDiagram-AXDQQJYC-D9KH5Zge.js} +1 -1
- package/dist/assets/react-vega-BTNeboR2.js +189 -0
- package/dist/assets/{readonly-code-display-Do50PHI8.js → readonly-code-display-C1eH5BDP.js} +1 -1
- package/dist/assets/{readonly-python-code-DCSMIsTX.js → readonly-python-code-BaElyj-4.js} +1 -1
- package/dist/assets/{renderShortcut-BkVJQ_Zh.js → renderShortcut-C1JwYcqW.js} +1 -1
- package/dist/assets/{reveal-component-ltaepAqw.js → reveal-component-FCuqfC5H.js} +3 -3
- package/dist/assets/{run-page-DF3c5cDk.js → run-page-CbyMlRt1.js} +1 -1
- package/dist/assets/{runtime-details-state-CSPobEdR.js → runtime-details-state-Bpn67vTN.js} +1 -1
- package/dist/assets/{schemas-Dv42wLFq.js → schemas-ChRqMOTL.js} +1 -1
- package/dist/assets/{scratchpad-panel-Dwd6W5PE.js → scratchpad-panel-OF9_e0l_.js} +1 -1
- package/dist/assets/{secrets-panel-Bctbtkrp.js → secrets-panel-D7KN4tVs.js} +1 -1
- package/dist/assets/session-panel-CExxjkjV.js +1 -0
- package/dist/assets/{snippets-panel-Bh1oTw4m.js → snippets-panel-DfRtTFJ-.js} +1 -1
- package/dist/assets/state-BUqF6L9e.js +3 -0
- package/dist/assets/{state-XsPO8hNi.js → state-CHojzKpZ.js} +1 -1
- package/dist/assets/{terminal-Ck_vK-34.js → terminal--4gDbK7q.js} +3 -3
- package/dist/assets/{time-MgToa8VM.js → time-DswSXBDk.js} +1 -1
- package/dist/assets/tooltip-BsSdc1NQ.js +61 -0
- package/dist/assets/{tracing-Di6pUmX8.js → tracing-BnfAB-BH.js} +1 -1
- package/dist/assets/{tracing-panel-PeRXHD8_.js → tracing-panel-DNabsbtA.js} +2 -2
- package/dist/assets/{tree-actions-GM5E_bUs.js → tree-actions-BXvnBv45.js} +1 -1
- package/dist/assets/{useAddCell-GR7eEQrT.js → useAddCell-6ECZ0xPG.js} +1 -1
- package/dist/assets/{useBoolean-BDD6_xQQ.js → useBoolean-BvAx9Qrr.js} +1 -1
- package/dist/assets/{useCellActionButton-B-9eG5z_.js → useCellActionButton-Djyiwayj.js} +1 -1
- package/dist/assets/{useDeleteCell-2_bMClHd.js → useDeleteCell-KX6SEwki.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-k3C3M18S.js → useDependencyPanelTab-B8ke9iTY.js} +1 -1
- package/dist/assets/{useInstallPackage-iX1eufdl.js → useInstallPackage-C9-icL78.js} +1 -1
- package/dist/assets/useNotebookActions-CluLEhAd.js +1 -0
- package/dist/assets/{useRunCells-B2vUhREg.js → useRunCells-B847kPLO.js} +1 -1
- package/dist/assets/{useSplitCell-DEb03htp.js → useSplitCell-CnvTmIw7.js} +1 -1
- package/dist/assets/{useTheme-COhSHkCj.js → useTheme-n8Dj1zH7.js} +1 -1
- package/dist/assets/{vega-component-CnadTgoE.js → vega-component-DAkdRNSF.js} +1 -1
- package/dist/assets/{vega-embed-container-DJPNkHPb.js → vega-embed-container-CxuIHhsS.js} +1 -1
- package/dist/assets/vega-loader.browser-Bu25TKax.js +6 -0
- package/dist/assets/{xychartDiagram-S5SC5T6Z-KbGzG6-u.js → xychartDiagram-S5SC5T6Z-CeRnBS5e.js} +1 -1
- package/dist/index.html +44 -45
- package/package.json +4 -2
- package/src/__tests__/fixtures/{pair-preview.ts → pair-config.ts} +3 -2
- package/src/__tests__/mount.test.ts +28 -8
- package/src/components/data-table/__tests__/context-menu.test.ts +176 -0
- package/src/components/data-table/__tests__/date-filter-inputs.test.tsx +24 -1
- package/src/components/data-table/__tests__/export-actions.test.tsx +329 -6
- package/src/components/data-table/context-menu.tsx +125 -26
- package/src/components/data-table/date-filter-inputs.tsx +15 -42
- package/src/components/data-table/export-actions.tsx +192 -116
- package/src/components/data-table/schemas.ts +14 -36
- package/src/components/data-table/utils.ts +18 -0
- package/src/components/editor/Output.tsx +9 -2
- package/src/components/editor/__tests__/dynamic-favicon.test.tsx +111 -18
- package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +80 -252
- package/src/components/editor/actions/__tests__/pair-with-agent-modal.test.tsx +186 -34
- package/src/components/editor/actions/pair-with-agent-commands.ts +42 -51
- package/src/components/editor/actions/pair-with-agent-modal.tsx +96 -36
- package/src/components/editor/chrome/panels/error-panel.tsx +1 -0
- package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +366 -0
- package/src/components/editor/chrome/wrapper/footer-item.tsx +32 -24
- package/src/components/editor/chrome/wrapper/footer-items/attachment-status.tsx +237 -0
- package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
- package/src/components/editor/controls/__tests__/command-palette.test.tsx +53 -0
- package/src/components/editor/controls/command-palette.tsx +22 -10
- package/src/components/editor/dynamic-favicon.tsx +21 -40
- package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +537 -0
- package/src/components/editor/errors/send-error-report-button.tsx +69 -0
- package/src/components/editor/output/MarimoErrorOutput.tsx +21 -5
- package/src/components/editor/output/MarimoTracebackOutput.tsx +35 -20
- package/src/components/editor/output/__tests__/ansi-reduce.test.ts +18 -0
- package/src/components/editor/output/ansi-reduce.ts +6 -0
- package/src/core/attachments/__tests__/toast.test.tsx +58 -0
- package/src/core/attachments/state.ts +18 -0
- package/src/core/attachments/toast.ts +15 -0
- package/src/core/cells/__tests__/cells.test.ts +52 -0
- package/src/core/cells/__tests__/error-report.test.ts +139 -0
- package/src/core/cells/__tests__/notebook-outline.test.ts +113 -0
- package/src/core/cells/__tests__/session.test.ts +50 -1
- package/src/core/cells/cells.ts +2 -1
- package/src/core/cells/error-report.ts +63 -0
- package/src/core/cells/session.ts +28 -1
- package/src/core/config/pair.ts +5 -3
- package/src/core/edit-app.tsx +2 -2
- package/src/core/host/AGENTS.md +5 -0
- package/src/core/host/__tests__/attachments.test.ts +57 -0
- package/src/core/host/__tests__/document.test.ts +124 -0
- package/src/core/host/__tests__/events.test.ts +46 -0
- package/src/core/host/__tests__/fixtures.ts +39 -0
- package/src/core/host/__tests__/notebook-client.test.ts +433 -0
- package/src/core/host/__tests__/protocol.test.ts +15 -0
- package/src/core/host/__tests__/stream.test.ts +209 -0
- package/src/core/host/connection.ts +81 -0
- package/src/core/host/events.ts +61 -0
- package/src/core/host/notebook-client.ts +290 -0
- package/src/core/host/protocol.ts +11 -6
- package/src/core/host/resources/attachments.ts +19 -0
- package/src/core/host/resources/document.ts +101 -0
- package/src/core/host/resources/resource.ts +47 -0
- package/src/core/host/stream.ts +211 -0
- package/src/core/network/connection.ts +4 -2
- package/src/core/run-app.tsx +2 -2
- package/src/core/runtime/__tests__/runtime.test.ts +55 -11
- package/src/core/runtime/runtime.ts +15 -1
- package/src/core/runtime/types.ts +2 -0
- package/src/core/websocket/__tests__/{useMarimoKernelConnection.test.ts → connection-state.test.ts} +15 -17
- package/src/core/websocket/__tests__/{useWebSocket.test.ts → createConnectionTransport.test.ts} +1 -1
- package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +170 -0
- package/src/core/websocket/__tests__/{useMarimoKernelConnection.hook.test.tsx → useNotebookSession.hook.test.tsx} +181 -65
- package/src/core/websocket/connection-state.ts +70 -0
- package/src/core/websocket/state.ts +7 -0
- package/src/core/websocket/transports/__tests__/sse.test.ts +1 -4
- package/src/core/websocket/transports/create.ts +31 -0
- package/src/core/websocket/transports/transport.ts +12 -23
- package/src/core/websocket/types.ts +9 -39
- package/src/core/websocket/useNotebookClient.ts +88 -0
- package/src/core/websocket/{useMarimoKernelConnection.tsx → useNotebookSession.tsx} +59 -225
- package/src/mount.tsx +7 -3
- package/src/plugins/impl/__tests__/DataTablePlugin.test.tsx +84 -0
- package/src/utils/__tests__/dates.test.ts +116 -0
- package/src/utils/dates.ts +94 -0
- package/src/utils/once.ts +19 -19
- package/dist/assets/JsonOutput-D6HIIsCb.js +0 -11
- package/dist/assets/MarimoErrorOutput-D60XkCny.js +0 -6
- package/dist/assets/ai-model-dropdown-B0z6-zhp.js +0 -5
- package/dist/assets/api-BeC_VSGv.js +0 -1
- package/dist/assets/code-visibility-B-_GvwR5.js +0 -1
- package/dist/assets/coerce-CpE6kUdX.js +0 -1
- package/dist/assets/command-palette-DTSQ7gOC.js +0 -1
- package/dist/assets/config-BOKzA3tF.js +0 -1
- package/dist/assets/config-DX8Y2WbJ.js +0 -1
- package/dist/assets/edit-page-ChcSgpke.js +0 -9
- package/dist/assets/feedback-button-B2gKC5cn.js +0 -1
- package/dist/assets/hooks-93IwHd2u.js +0 -1
- package/dist/assets/index-Bcdzex-3.css +0 -2
- package/dist/assets/index-s-M1OXjs.js +0 -29
- package/dist/assets/linear-0eij_aAG.js +0 -1
- package/dist/assets/pair-CaQyK6En.js +0 -1
- package/dist/assets/panels-CxVG8-v2.js +0 -4
- package/dist/assets/precisionRound-Bq_nmf5u.js +0 -1
- package/dist/assets/react-vega-C3rYGtP6.js +0 -189
- package/dist/assets/session-panel-VdDhEBa-.js +0 -1
- package/dist/assets/state-_Qai_QyX.js +0 -3
- package/dist/assets/tooltip-mT3CGest.js +0 -61
- package/dist/assets/useNotebookActions-NHN6A-cN.js +0 -1
- package/dist/assets/vega-loader.browser-CLRkNgm7.js +0 -6
- package/src/core/websocket/useWebSocket.tsx +0 -88
|
@@ -0,0 +1,433 @@
|
|
|
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 { fixtureEvents, 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
|
+
});
|
|
304
|
+
|
|
305
|
+
async function open() {
|
|
306
|
+
const url = vi.fn(() => "ws://localhost/ws?session_id=first&cursor=999");
|
|
307
|
+
const setupResult = setup(url);
|
|
308
|
+
const lifetime = new AbortController();
|
|
309
|
+
setupResult.connect(lifetime.signal);
|
|
310
|
+
setupResult.healthy.resolve();
|
|
311
|
+
await setupResult.healthy.promise;
|
|
312
|
+
setupResult.events.dispatchEvent(new Event("open"));
|
|
313
|
+
return { ...setupResult, lifetime, url };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
describe("NotebookClient attachments", () => {
|
|
317
|
+
const [browser, agent] = fixtureEvents("notebook-events.sse").flatMap(
|
|
318
|
+
(event) => (event.event === "attachment" ? [event.data.attachment] : []),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
it("publishes a complete snapshot and applies live changes independently of kernel readiness", async () => {
|
|
322
|
+
const { client, store, receive, lifetime } = await open();
|
|
323
|
+
const snapshot = fixtureMessages("notebook-events.sse");
|
|
324
|
+
const readyIndex = snapshot.findIndex((event) => event.event === "ready");
|
|
325
|
+
snapshot.slice(0, readyIndex).forEach(receive);
|
|
326
|
+
expect(store.get(client.attachmentsAtom)).toEqual([]);
|
|
327
|
+
snapshot.slice(readyIndex).forEach(receive);
|
|
328
|
+
expect(store.get(client.attachmentsAtom)).toEqual([browser, agent]);
|
|
329
|
+
const renamed = { ...agent, name: "Renamed agent" };
|
|
330
|
+
receive({ event: "attachment", id: "98", data: { attachment: renamed } });
|
|
331
|
+
receive({
|
|
332
|
+
event: "attachment.removed",
|
|
333
|
+
id: "99",
|
|
334
|
+
data: { id: browser.id },
|
|
335
|
+
});
|
|
336
|
+
expect(store.get(client.attachmentsAtom)).toEqual([renamed]);
|
|
337
|
+
receive({ data: { op: "kernel-ready", resumed: false } });
|
|
338
|
+
expect(store.get(client.attachmentsAtom)).toEqual([renamed]);
|
|
339
|
+
receive({ event: "attachment.removed", id: "100", data: { id: agent.id } });
|
|
340
|
+
expect(store.get(client.attachmentsAtom)).toEqual([]);
|
|
341
|
+
lifetime.abort();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("resumes attachments alongside an incomplete document and waits for replay ready", async () => {
|
|
345
|
+
const { client, store, receive, events, urls, lifetime } = await open();
|
|
346
|
+
expect(urls[0].searchParams.has("cursor")).toBe(false);
|
|
347
|
+
fixtureMessages("notebook-events.sse").forEach(receive);
|
|
348
|
+
const confirmed = store.get(client.documentAtom);
|
|
349
|
+
const edits = fixtureMessages("edits.sse");
|
|
350
|
+
edits.slice(0, 3).forEach(receive);
|
|
351
|
+
const renamed = { ...agent, name: "Renamed agent" };
|
|
352
|
+
receive({ event: "attachment", id: "112a", data: { attachment: renamed } });
|
|
353
|
+
expect(store.get(client.attachmentsAtom)).toEqual([browser, renamed]);
|
|
354
|
+
events.dispatchEvent(new CloseEvent("close"));
|
|
355
|
+
expect(store.get(client.attachmentsAtom)).toEqual([]);
|
|
356
|
+
expect(urls.at(-1)!.searchParams.get("cursor")).toBe("112a");
|
|
357
|
+
events.dispatchEvent(new Event("open"));
|
|
358
|
+
expect(store.get(client.attachmentsAtom)).toEqual([]);
|
|
359
|
+
expect(store.get(client.documentAtom)).toBe(confirmed);
|
|
360
|
+
edits.slice(3).forEach(receive);
|
|
361
|
+
receive({
|
|
362
|
+
event: "attachment.removed",
|
|
363
|
+
id: "115",
|
|
364
|
+
data: { id: browser.id },
|
|
365
|
+
});
|
|
366
|
+
expect(store.get(client.attachmentsAtom)).toEqual([]);
|
|
367
|
+
receive({ event: "ready", id: "116", data: {} });
|
|
368
|
+
expect(store.get(client.attachmentsAtom)).toEqual([renamed]);
|
|
369
|
+
const revisions = fixtureEvents("edits.sse").flatMap((event) =>
|
|
370
|
+
event.event === "document" ? [event.data.document] : [],
|
|
371
|
+
);
|
|
372
|
+
expect(store.get(client.documentAtom)?.revision).toEqual(revisions.at(-1));
|
|
373
|
+
lifetime.abort();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("replaces stale attachments with an empty snapshot after reset", async () => {
|
|
377
|
+
const { client, store, receive, lifetime } = await open();
|
|
378
|
+
fixtureMessages("notebook-events.sse").forEach(receive);
|
|
379
|
+
receive({ event: "reset", data: {} });
|
|
380
|
+
expect(store.get(client.attachmentsAtom)).toEqual([]);
|
|
381
|
+
fixtureMessages("notebook-events.sse")
|
|
382
|
+
.filter((event) => event.event !== "attachment")
|
|
383
|
+
.forEach(receive);
|
|
384
|
+
expect(store.get(client.attachmentsAtom)).toEqual([]);
|
|
385
|
+
lifetime.abort();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it.each(["close", "error", "malformed", "removed", "abort", "identity"])(
|
|
389
|
+
"clears presence on %s and ignores messages after disposal",
|
|
390
|
+
async (reason) => {
|
|
391
|
+
const { client, store, receive, events, url, lifetime } = await open();
|
|
392
|
+
fixtureMessages("notebook-events.sse").forEach(receive);
|
|
393
|
+
expect(store.get(client.attachmentsAtom)).toEqual([browser, agent]);
|
|
394
|
+
switch (reason) {
|
|
395
|
+
case "close":
|
|
396
|
+
events.dispatchEvent(
|
|
397
|
+
new CloseEvent("close", { reason: "MARIMO_UNAUTHORIZED" }),
|
|
398
|
+
);
|
|
399
|
+
break;
|
|
400
|
+
case "error":
|
|
401
|
+
events.dispatchEvent(new Event("error"));
|
|
402
|
+
break;
|
|
403
|
+
case "malformed":
|
|
404
|
+
receive({ event: "attachment", id: "98", data: {} });
|
|
405
|
+
break;
|
|
406
|
+
case "removed":
|
|
407
|
+
receive({ event: "notebook.removed", id: "98", data: { id: "n1" } });
|
|
408
|
+
break;
|
|
409
|
+
case "identity":
|
|
410
|
+
url.mockReturnValue(
|
|
411
|
+
"ws://localhost/api/marimo/v1/notebooks/n2/events/ws",
|
|
412
|
+
);
|
|
413
|
+
events.dispatchEvent(new CloseEvent("close"));
|
|
414
|
+
receive({ event: "ready", id: "98", data: {} });
|
|
415
|
+
break;
|
|
416
|
+
default:
|
|
417
|
+
lifetime.abort();
|
|
418
|
+
}
|
|
419
|
+
expect(store.get(client.attachmentsAtom)).toEqual([]);
|
|
420
|
+
lifetime.abort();
|
|
421
|
+
fixtureMessages("notebook-events.sse").forEach(receive);
|
|
422
|
+
expect(store.get(client.attachmentsAtom)).toEqual([]);
|
|
423
|
+
},
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
it("keeps legacy sessions empty", async () => {
|
|
427
|
+
const { client, store, receive, lifetime } = await open();
|
|
428
|
+
receive({ data: { op: "kernel-ready" } });
|
|
429
|
+
receive({ data: { op: "reconnected" } });
|
|
430
|
+
expect(store.get(client.attachmentsAtom)).toEqual([]);
|
|
431
|
+
lifetime.abort();
|
|
432
|
+
});
|
|
433
|
+
});
|
|
@@ -19,6 +19,7 @@ function readFixture(name: string): unknown {
|
|
|
19
19
|
const records = {
|
|
20
20
|
"record.json": Protocol.HostRecord,
|
|
21
21
|
"cell.json": Protocol.Cell,
|
|
22
|
+
"cell-withheld.json": Protocol.Cell,
|
|
22
23
|
"analysis.json": Protocol.Analysis,
|
|
23
24
|
"diagnostic.json": Protocol.Error,
|
|
24
25
|
"edit.json": Protocol.EditRequest,
|
|
@@ -111,6 +112,20 @@ describe("host protocol bindings", () => {
|
|
|
111
112
|
expect(Protocol.Document.parse(configured)).toEqual(configured);
|
|
112
113
|
});
|
|
113
114
|
|
|
115
|
+
it("requires source in edits while allowing withheld source in records", () => {
|
|
116
|
+
expectTypeOf<Protocol.Cell["code"]>().toEqualTypeOf<string | null>();
|
|
117
|
+
expectTypeOf<Protocol.CellInput["code"]>().toEqualTypeOf<string>();
|
|
118
|
+
const cell = Protocol.Cell.parse(readFixture("cell.json"));
|
|
119
|
+
const { code, ...missing } = cell;
|
|
120
|
+
expect(Protocol.Cell.safeParse(missing).success).toBe(false);
|
|
121
|
+
expect(Protocol.Cell.parse({ ...cell, code: "" }).code).toBe("");
|
|
122
|
+
for (const input of [{ id: cell.id }, { id: cell.id, code: null }]) {
|
|
123
|
+
expect(Protocol.CellInput.safeParse(input).success).toBe(false);
|
|
124
|
+
}
|
|
125
|
+
expect(Protocol.CellInput.parse({ id: cell.id, code }).code).toBe(code);
|
|
126
|
+
expect(Protocol.CellInput.parse({ id: cell.id, code: "" }).code).toBe("");
|
|
127
|
+
});
|
|
128
|
+
|
|
114
129
|
it("applies the defaults declared for cell inputs", () => {
|
|
115
130
|
expect(
|
|
116
131
|
Protocol.CellInput.parse({ id: "c3", code: "x = 1", config: {} }),
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { NotebookEvent } from "../events";
|
|
5
|
+
import type { Resource } from "../resources/resource";
|
|
6
|
+
import { createStream } from "../stream";
|
|
7
|
+
import { fixtureEvents } from "./fixtures";
|
|
8
|
+
|
|
9
|
+
interface CellIds {
|
|
10
|
+
readonly ids: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A resource whose events are complete on their own. */
|
|
14
|
+
const cells: Resource<CellIds> = {
|
|
15
|
+
initial: { ids: [] },
|
|
16
|
+
on: {
|
|
17
|
+
cell: (state, event) =>
|
|
18
|
+
state.ids.includes(event.data.cell.id)
|
|
19
|
+
? state
|
|
20
|
+
: { ids: [...state.ids, event.data.cell.id] },
|
|
21
|
+
"cell.removed": (state, event) =>
|
|
22
|
+
state.ids.includes(event.data.id)
|
|
23
|
+
? { ids: state.ids.filter((id) => id !== event.data.id) }
|
|
24
|
+
: state,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
interface Revisions {
|
|
29
|
+
/** Cells changed since the last revision. */
|
|
30
|
+
readonly pending: readonly string[];
|
|
31
|
+
readonly published: readonly (readonly string[])[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function publish(state: Revisions): Revisions {
|
|
35
|
+
return { pending: [], published: [...state.published, state.pending] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A resource assembled from groups of events, like a document from its cells. */
|
|
39
|
+
const revisions: Resource<Revisions> = {
|
|
40
|
+
initial: { pending: [], published: [] },
|
|
41
|
+
on: {
|
|
42
|
+
cell: (state, event) => ({
|
|
43
|
+
...state,
|
|
44
|
+
pending: [...state.pending, event.data.cell.id],
|
|
45
|
+
}),
|
|
46
|
+
"cell.removed": (state, event) => ({
|
|
47
|
+
...state,
|
|
48
|
+
pending: [...state.pending, event.data.id],
|
|
49
|
+
}),
|
|
50
|
+
// A live revision completes a group. A snapshot's revision waits for ready.
|
|
51
|
+
document: (state, _event, { phase }) =>
|
|
52
|
+
phase === "live" ? publish(state) : state,
|
|
53
|
+
ready: publish,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function createTestStream() {
|
|
58
|
+
return createStream({ cells, revisions });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function replay(
|
|
62
|
+
stream: ReturnType<typeof createTestStream>,
|
|
63
|
+
events: readonly NotebookEvent[],
|
|
64
|
+
from = stream.initial,
|
|
65
|
+
) {
|
|
66
|
+
return events.reduce((state, event) => stream.reduce(state, event), from);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const snapshot = fixtureEvents("notebook-events.sse");
|
|
70
|
+
const ready = snapshot.findIndex((event) => event.event === "ready");
|
|
71
|
+
const cellEvent = snapshot.find((event) => event.event === "cell");
|
|
72
|
+
if (cellEvent?.event !== "cell") {
|
|
73
|
+
throw new Error("Fixture is missing a cell event");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("notebook stream", () => {
|
|
77
|
+
it("reads the snapshot, goes live at ready, and rebuilds after reset", () => {
|
|
78
|
+
const stream = createTestStream();
|
|
79
|
+
const partial = replay(stream, snapshot.slice(0, ready));
|
|
80
|
+
expect(partial).toMatchObject({
|
|
81
|
+
phase: "snapshot",
|
|
82
|
+
notebookId: "n1",
|
|
83
|
+
cursor: null,
|
|
84
|
+
});
|
|
85
|
+
expect(partial.resources.cells.ids).toEqual(["c1", "c3", "k7f2"]);
|
|
86
|
+
expect(partial.resources.revisions).toEqual({
|
|
87
|
+
pending: ["c1", "c3", "k7f2"],
|
|
88
|
+
published: [],
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const live = replay(stream, snapshot.slice(ready), partial);
|
|
92
|
+
expect(live).toMatchObject({ phase: "live", cursor: "97" });
|
|
93
|
+
expect(live.resources.cells).toBe(partial.resources.cells);
|
|
94
|
+
expect(live.resources.revisions).toEqual({
|
|
95
|
+
pending: [],
|
|
96
|
+
published: [["c1", "c3", "k7f2"]],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const resetEvents = fixtureEvents("events-reset.sse");
|
|
100
|
+
const afterReset = stream.reduce(live, resetEvents[0]);
|
|
101
|
+
expect(afterReset).toEqual({ ...stream.initial, notebookId: "n1" });
|
|
102
|
+
expect(replay(stream, resetEvents, live).phase).toBe("live");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("keeps resource state by reference when an event does not apply", () => {
|
|
106
|
+
const stream = createTestStream();
|
|
107
|
+
const state = replay(stream, snapshot);
|
|
108
|
+
const runtime = snapshot.find((event) => event.event === "runtime");
|
|
109
|
+
const unhandled = stream.reduce(state, { ...runtime!, id: "100" });
|
|
110
|
+
expect(unhandled.resources).toBe(state.resources);
|
|
111
|
+
expect(unhandled.cursor).toBe("100");
|
|
112
|
+
expect(
|
|
113
|
+
stream.reduce(state, { event: "unknown", name: "x", id: "101" })
|
|
114
|
+
.resources,
|
|
115
|
+
).toBe(state.resources);
|
|
116
|
+
expect(
|
|
117
|
+
stream.reduce(state, { event: "marimo", data: { marimo: {} } }),
|
|
118
|
+
).toBe(state);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("clears resources when its notebook is removed and ignores other notebooks", () => {
|
|
122
|
+
const stream = createTestStream();
|
|
123
|
+
const state = replay(stream, snapshot);
|
|
124
|
+
const time = "2026-10-01T09:30:00Z";
|
|
125
|
+
expect(
|
|
126
|
+
stream.reduce(state, {
|
|
127
|
+
event: "notebook.removed",
|
|
128
|
+
id: "100",
|
|
129
|
+
data: { id: "n2", time },
|
|
130
|
+
}).resources,
|
|
131
|
+
).toBe(state.resources);
|
|
132
|
+
const removed = stream.reduce(state, {
|
|
133
|
+
event: "notebook.removed",
|
|
134
|
+
id: "100",
|
|
135
|
+
data: { id: "n1", time },
|
|
136
|
+
});
|
|
137
|
+
expect(removed).toMatchObject({ phase: "live", notebookId: "n1" });
|
|
138
|
+
expect(removed.resources).toBe(stream.initial.resources);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("rejects objects from another notebook", () => {
|
|
142
|
+
const stream = createTestStream();
|
|
143
|
+
const state = replay(stream, snapshot.slice(0, ready));
|
|
144
|
+
const notebook = snapshot[0];
|
|
145
|
+
if (notebook.event !== "notebook") {
|
|
146
|
+
throw new Error("Fixture does not start with its notebook");
|
|
147
|
+
}
|
|
148
|
+
expect(() =>
|
|
149
|
+
stream.reduce(state, {
|
|
150
|
+
...notebook,
|
|
151
|
+
data: { notebook: { ...notebook.data.notebook, id: "n2" } },
|
|
152
|
+
}),
|
|
153
|
+
).toThrow("changed identity");
|
|
154
|
+
expect(() =>
|
|
155
|
+
stream.reduce(state, {
|
|
156
|
+
...cellEvent,
|
|
157
|
+
data: { cell: { ...cellEvent.data.cell, notebook_id: "n2" } },
|
|
158
|
+
}),
|
|
159
|
+
).toThrow("changed identity");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("records each live event's cursor and resumes in place", () => {
|
|
163
|
+
const stream = createTestStream();
|
|
164
|
+
const partial = replay(stream, snapshot.slice(0, ready));
|
|
165
|
+
expect(stream.resume(partial)).toEqual({
|
|
166
|
+
...stream.initial,
|
|
167
|
+
notebookId: "n1",
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const live = replay(stream, snapshot.slice(ready), partial);
|
|
171
|
+
const [cell, , removed, published, saved] = fixtureEvents("edits.sse");
|
|
172
|
+
const editing = replay(stream, [cell], live);
|
|
173
|
+
expect(editing.cursor).toBe("110");
|
|
174
|
+
expect(editing.resources.revisions.pending).toEqual(["c2"]);
|
|
175
|
+
expect(stream.resume(editing)).toBe(editing);
|
|
176
|
+
|
|
177
|
+
const edited = replay(stream, [removed, published], editing);
|
|
178
|
+
expect(edited.cursor).toBe("113");
|
|
179
|
+
expect(edited.resources.revisions).toEqual({
|
|
180
|
+
pending: [],
|
|
181
|
+
published: [
|
|
182
|
+
["c1", "c3", "k7f2"],
|
|
183
|
+
["c2", "k7f2"],
|
|
184
|
+
],
|
|
185
|
+
});
|
|
186
|
+
expect(stream.reduce(edited, saved).cursor).toBe("114");
|
|
187
|
+
expect(
|
|
188
|
+
stream.reduce(edited, { event: "ready", id: "116", data: {} }).cursor,
|
|
189
|
+
).toBe("116");
|
|
190
|
+
expect(stream.reduce(edited, { event: "reset", data: {} }).cursor).toBe(
|
|
191
|
+
null,
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("requires a cursor on live events other than reset and notifications", () => {
|
|
196
|
+
const stream = createTestStream();
|
|
197
|
+
const state = replay(stream, snapshot);
|
|
198
|
+
expect(() => stream.reduce(state, cellEvent)).toThrow("cursor");
|
|
199
|
+
expect(
|
|
200
|
+
stream.reduce(state, { ...cellEvent, id: "100" }).resources.cells,
|
|
201
|
+
).toBe(state.resources.cells);
|
|
202
|
+
expect(stream.reduce(state, { event: "reset", data: {} }).phase).toBe(
|
|
203
|
+
"snapshot",
|
|
204
|
+
);
|
|
205
|
+
expect(
|
|
206
|
+
stream.reduce(state, { event: "marimo", data: { marimo: null } }),
|
|
207
|
+
).toBe(state);
|
|
208
|
+
});
|
|
209
|
+
});
|