@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.
- package/dist/assets/{CellStatus-cD3S0wEA.js → CellStatus-DXroYLOP.js} +1 -1
- package/dist/assets/{ErrorBoundary-DJhsFCsX.js → ErrorBoundary-DaQ9THH_.js} +2 -2
- package/dist/assets/{JsonOutput-Pm6OCP3J.js → JsonOutput-VxzHyItt.js} +1 -1
- package/dist/assets/{MarimoErrorOutput-DlEjLCZ-.js → MarimoErrorOutput-D-hLaDA-.js} +1 -1
- package/dist/assets/{RenderHTML-CfQ6i3QV.js → RenderHTML-DjOEZL25.js} +1 -1
- package/dist/assets/{RunButton-DvuvkgrG.js → RunButton-CuhNTo5_.js} +1 -1
- package/dist/assets/{add-cell-with-ai-By51zSlC.js → add-cell-with-ai-BbVntTh7.js} +1 -1
- package/dist/assets/{add-connection-dialog-D7EYSraq.js → add-connection-dialog-Qf63aKV6.js} +1 -1
- package/dist/assets/{agent-panel-aCWa3Yai.js → agent-panel-DMOfF3qF.js} +1 -1
- package/dist/assets/{ai-model-dropdown-bwTRyFn4.js → ai-model-dropdown-B15l0R62.js} +2 -2
- package/dist/assets/{app-config-button-DGhsM0xw.js → app-config-button-BIMB8qjD.js} +1 -1
- package/dist/assets/{blob-e6VlOmQP.js → blob-De9GR2s6.js} +1 -1
- package/dist/assets/{cell-editor-M8dEnDzk.js → cell-editor-DFNLDjfX.js} +3 -3
- package/dist/assets/{cell-link-DFqfW-Ym.js → cell-link-DR3W2HC7.js} +1 -1
- package/dist/assets/{cells-T9_Y4Jlv.js → cells-styV7IX8.js} +6 -6
- package/dist/assets/{chat-display-DXn2Yrjr.js → chat-display-BzdYdN39.js} +1 -1
- package/dist/assets/{chat-panel-D3iUyZId.js → chat-panel-De3YzkSF.js} +1 -1
- package/dist/assets/{chat-ui-YuJtv3UJ.js → chat-ui-NTNapdtP.js} +1 -1
- package/dist/assets/{code-visibility-CV3S4xuf.js → code-visibility-csc3tgA0.js} +1 -1
- package/dist/assets/{column-preview-C6uXRJry.js → column-preview-DkH-oD8f.js} +1 -1
- package/dist/assets/{command-palette-B5H-9JMB.js → command-palette-OOQHBTnr.js} +1 -1
- package/dist/assets/{common-DeeLgjWQ.js → common-D7VAS6rd.js} +1 -1
- package/dist/assets/components-CJRBUI2s.js +1 -0
- package/dist/assets/{components-DyAF3_Nf.js → components-CNmvC781.js} +1 -1
- package/dist/assets/config-BQT0uYUo.js +1 -0
- package/dist/assets/{config-CjQ4WJnJ.js → config-BbRbT9Ww.js} +1 -1
- package/dist/assets/{datasource-gqFgG9Qd.js → datasource-C3yfeDa2.js} +1 -1
- package/dist/assets/{dependency-graph-panel-BFFxLgR6.js → dependency-graph-panel-CjoA6L0w.js} +1 -1
- package/dist/assets/{documentation-panel-BBpGbT-f.js → documentation-panel-DHWFOiG1.js} +1 -1
- package/dist/assets/{download-C8Gw-D0Y.js → download-BrCpRZKN.js} +1 -1
- package/dist/assets/{edit-page-D5LKeAwW.js → edit-page-DafDcIAI.js} +6 -6
- package/dist/assets/{error-entries-uyqOG5tg.js → error-entries-BkEAmmnD.js} +1 -1
- package/dist/assets/{error-panel-Cli5zPWi.js → error-panel-BhTfPA_i.js} +1 -1
- package/dist/assets/feedback-button-BAcClv7c.js +1 -0
- package/dist/assets/{feedback-button-CvrwBGr7.js → feedback-button-D9InK87G.js} +1 -1
- package/dist/assets/{file-explorer-panel-BSU5ultY.js → file-explorer-panel-BSd_ZbUC.js} +1 -1
- package/dist/assets/{file-name-input-CDrRlIiX.js → file-name-input-RHURglL_.js} +1 -1
- package/dist/assets/{floating-outline-GQD9km0W.js → floating-outline-Cm49rYYe.js} +1 -1
- package/dist/assets/{gallery-page-CxmV1JLF.js → gallery-page-DdMdOtdv.js} +1 -1
- package/dist/assets/{glide-data-editor-B1kpoetH.js → glide-data-editor-eiSlEUyR.js} +1 -1
- package/dist/assets/home-page-DwWvBB0R.js +1 -0
- package/dist/assets/{hooks-BrFYiTJS.js → hooks-C4GjmtQ-.js} +1 -1
- package/dist/assets/index-BdsOMJac.css +2 -0
- package/dist/assets/{index-BU1n9Awq.js → index-jR_KRDMC.js} +8 -8
- package/dist/assets/{layout-CGC8ajpe.js → layout-Bq7wRzdR.js} +3 -3
- package/dist/assets/{logs-panel-CdUc5vtA.js → logs-panel-CAAk72Gb.js} +1 -1
- package/dist/assets/{markdown-renderer-pKc_bFac.js → markdown-renderer-CAyCgogC.js} +1 -1
- package/dist/assets/{name-cell-input-csH3Cy54.js → name-cell-input-DA8hU9XT.js} +1 -1
- package/dist/assets/{outline-panel-DhsHYTuo.js → outline-panel-Cc5oQ4LL.js} +1 -1
- package/dist/assets/{packages-panel-BtkXVz3L.js → packages-panel-DTfKez6I.js} +1 -1
- package/dist/assets/{panel-accordion-state-Bco6nsUI.js → panel-accordion-state-BZHex38W.js} +1 -1
- package/dist/assets/panels-Cnu2Flfw.js +105 -0
- package/dist/assets/{process-output-B2-bT4cq.js → process-output-CrDc_O1D.js} +1 -1
- package/dist/assets/{readonly-code-display-Dsp6vWPh.js → readonly-code-display-BR-_jUj8.js} +1 -1
- package/dist/assets/{readonly-python-code-B81AjzXM.js → readonly-python-code-D7jcy9ds.js} +1 -1
- package/dist/assets/{reveal-component-Cxo3hLGA.js → reveal-component-BWu6kkxZ.js} +1 -1
- package/dist/assets/{run-page-CLP1s8uX.js → run-page-BJq9MULx.js} +1 -1
- package/dist/assets/{runtime-details-state-CznIe40L.js → runtime-details-state-hb7A1jgR.js} +1 -1
- package/dist/assets/{scratchpad-panel-yHIW63VQ.js → scratchpad-panel-OHQ7Z0RY.js} +1 -1
- package/dist/assets/{session-panel-C7-wmZhc.js → session-panel-DmDjaPsN.js} +1 -1
- package/dist/assets/{snippets-panel-BARpOPyS.js → snippets-panel-o9KL5jMi.js} +1 -1
- package/dist/assets/state-BkAqJoW7.js +3 -0
- package/dist/assets/{state-Bye6rCo4.js → state-Os7LSUUZ.js} +1 -1
- package/dist/assets/{terminal-Bhcn_y1N.js → terminal-BI85khrS.js} +1 -1
- package/dist/assets/tracing-AsNGuDsh.js +1 -0
- package/dist/assets/{tracing-panel-UKdh_YXC.js → tracing-panel-CBcmea6L.js} +2 -2
- package/dist/assets/{tree-actions-Cb-nUK1F.js → tree-actions-Cx5EDpA6.js} +1 -1
- package/dist/assets/{useAddCell-Bielffe0.js → useAddCell-uJJYtgwa.js} +1 -1
- package/dist/assets/{useBoolean-CMgj9hnq.js → useBoolean-8wlrTeus.js} +1 -1
- package/dist/assets/useCellActionButton-CmQ-OcPa.js +1 -0
- package/dist/assets/{useDeleteCell-BV4VemCv.js → useDeleteCell-CXgOCDqY.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-BEGqRiWi.js → useDependencyPanelTab-Ch6gm7uM.js} +1 -1
- package/dist/assets/{useInstallPackage-C7neMPwh.js → useInstallPackage-CYQoMdMQ.js} +1 -1
- package/dist/assets/useNotebookActions-CqXa3U6u.js +1 -0
- package/dist/assets/{useRunCells-BFeZMi7f.js → useRunCells-DAfDmOMC.js} +1 -1
- package/dist/assets/{useSplitCell-DllkNFke.js → useSplitCell-NLULgcq7.js} +1 -1
- package/dist/assets/{vega-component-D7JFthDD.js → vega-component-BKJU3uaB.js} +1 -1
- package/dist/index.html +25 -25
- package/package.json +1 -1
- package/src/components/home/components.tsx +17 -31
- package/src/components/pages/home-page.tsx +141 -89
- package/src/core/host/__tests__/notebook-client.test.ts +368 -350
- package/src/core/host/notebook-client.ts +129 -186
- package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +133 -59
- package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +152 -250
- package/src/core/websocket/transports/__tests__/notebook.test.ts +502 -0
- package/src/core/websocket/transports/notebook.ts +213 -0
- package/src/core/websocket/useNotebookClient.ts +28 -17
- package/src/css/globals.css +3 -0
- package/dist/assets/circle-play-CYIb0qT6.js +0 -1
- package/dist/assets/components-lDm0qi2t.js +0 -1
- package/dist/assets/config-DzdxZb17.js +0 -1
- package/dist/assets/feedback-button-DknbRCN_.js +0 -1
- package/dist/assets/home-page-CZ3hkD3m.js +0 -4
- package/dist/assets/index-evFWHKmM.css +0 -2
- package/dist/assets/link-NuFVR7G9.js +0 -1
- package/dist/assets/panels-D1i80TGn.js +0 -107
- package/dist/assets/state-BRYi026f.js +0 -3
- package/dist/assets/tracing-BW2ZVnXs.js +0 -1
- package/dist/assets/useCellActionButton-Cu34Zs6x.js +0 -1
- package/dist/assets/useNotebookActions-CmqOx-WZ.js +0 -1
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
NotebookEvents,
|
|
5
|
+
StreamStopped,
|
|
6
|
+
type StreamUrl,
|
|
7
|
+
} from "@marimo-team/client";
|
|
8
|
+
import { createStore } from "jotai/vanilla";
|
|
9
|
+
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
|
|
10
|
+
import { createNotebookClient } from "../../../host/notebook-client";
|
|
11
|
+
import { fixtureMessages } from "../../../host/__tests__/fixtures";
|
|
12
|
+
import { ConnectionState } from "../../../host/connection";
|
|
13
|
+
import { Deferred } from "@/utils/Deferred";
|
|
14
|
+
import {
|
|
15
|
+
createNotebookTransport,
|
|
16
|
+
pyodide,
|
|
17
|
+
sessionSse,
|
|
18
|
+
websocket,
|
|
19
|
+
} from "../notebook";
|
|
20
|
+
|
|
21
|
+
const wasm = vi.hoisted(() => ({ enabled: false, consumer: vi.fn() }));
|
|
22
|
+
vi.mock("../../../wasm/utils", () => ({ isWasm: () => wasm.enabled }));
|
|
23
|
+
vi.mock("../../../wasm/bridge", () => ({
|
|
24
|
+
PyodideBridge: { INSTANCE: { attachMessageConsumer: wasm.consumer } },
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const encoder = new TextEncoder();
|
|
28
|
+
|
|
29
|
+
/** A WebSocket the test drives. */
|
|
30
|
+
class FakeSocket extends EventTarget {
|
|
31
|
+
public static readonly opened: FakeSocket[] = [];
|
|
32
|
+
public readonly url: string;
|
|
33
|
+
public constructor(url: URL) {
|
|
34
|
+
super();
|
|
35
|
+
this.url = String(url);
|
|
36
|
+
FakeSocket.opened.push(this);
|
|
37
|
+
}
|
|
38
|
+
public open() {
|
|
39
|
+
this.dispatchEvent(new Event("open"));
|
|
40
|
+
}
|
|
41
|
+
public receive(text: string) {
|
|
42
|
+
this.dispatchEvent(new MessageEvent("message", { data: text }));
|
|
43
|
+
}
|
|
44
|
+
public close(code = 1006, reason = "") {
|
|
45
|
+
this.dispatchEvent(Object.assign(new Event("close"), { code, reason }));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** An SSE response body the test writes to. */
|
|
50
|
+
function body() {
|
|
51
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
52
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
53
|
+
start(c) {
|
|
54
|
+
controller = c;
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
response: new Response(stream, {
|
|
59
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
60
|
+
}),
|
|
61
|
+
push: (text: string) => controller.enqueue(encoder.encode(text)),
|
|
62
|
+
end: () => controller.close(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function collect(events: NotebookEvents<string>) {
|
|
67
|
+
const received: unknown[] = [];
|
|
68
|
+
events.subscribe((event) => received.push(event));
|
|
69
|
+
return received;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Drive the real session adapter through either network protocol. */
|
|
73
|
+
function network(transportType: "websocket" | "sse", url: StreamUrl) {
|
|
74
|
+
const requests: { request: Request; stream: ReturnType<typeof body> }[] = [];
|
|
75
|
+
vi.stubGlobal("WebSocket", FakeSocket);
|
|
76
|
+
vi.stubGlobal(
|
|
77
|
+
"fetch",
|
|
78
|
+
async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
79
|
+
const stream = body();
|
|
80
|
+
requests.push({ request: new Request(input, init), stream });
|
|
81
|
+
return stream.response;
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
const count = () =>
|
|
85
|
+
transportType === "websocket" ? FakeSocket.opened.length : requests.length;
|
|
86
|
+
const transport = createNotebookTransport({
|
|
87
|
+
static: false,
|
|
88
|
+
transportType,
|
|
89
|
+
url,
|
|
90
|
+
headers: () => ({}),
|
|
91
|
+
});
|
|
92
|
+
let index = 0;
|
|
93
|
+
return {
|
|
94
|
+
transport,
|
|
95
|
+
count,
|
|
96
|
+
opened: async () => {
|
|
97
|
+
await vi.waitFor(() => expect(count()).toBeGreaterThan(index));
|
|
98
|
+
const next = index++;
|
|
99
|
+
if (transportType === "websocket") {
|
|
100
|
+
const socket = FakeSocket.opened[next];
|
|
101
|
+
socket.open();
|
|
102
|
+
return {
|
|
103
|
+
cursor: new URL(socket.url).searchParams.get("cursor"),
|
|
104
|
+
receive: (frame: { event: string; data: unknown; id?: string }) =>
|
|
105
|
+
socket.receive(JSON.stringify(frame)),
|
|
106
|
+
drop: () => socket.close(),
|
|
107
|
+
stop: () => socket.close(1000, "MARIMO_SHUTDOWN"),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const { request, stream } = requests[next];
|
|
111
|
+
return {
|
|
112
|
+
cursor: request.headers.get("Last-Event-ID"),
|
|
113
|
+
receive: (frame: { event: string; data: unknown; id?: string }) =>
|
|
114
|
+
stream.push(
|
|
115
|
+
`${frame.id === undefined ? "" : `id: ${frame.id}\n`}event: ${frame.event}\ndata: ${JSON.stringify(frame.data)}\n\n`,
|
|
116
|
+
),
|
|
117
|
+
drop: stream.end,
|
|
118
|
+
stop: () =>
|
|
119
|
+
stream.push(
|
|
120
|
+
'event: close\ndata: {"code":1000,"reason":"MARIMO_SHUTDOWN"}\n\n',
|
|
121
|
+
),
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
afterEach(() => {
|
|
128
|
+
FakeSocket.opened.length = 0;
|
|
129
|
+
wasm.enabled = false;
|
|
130
|
+
wasm.consumer.mockReset();
|
|
131
|
+
vi.unstubAllGlobals();
|
|
132
|
+
vi.useRealTimers();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("websocket", () => {
|
|
136
|
+
it("resumes with the cursor and reads data with marimo's parser", async () => {
|
|
137
|
+
vi.useFakeTimers();
|
|
138
|
+
vi.stubGlobal("WebSocket", FakeSocket);
|
|
139
|
+
const events = new NotebookEvents(
|
|
140
|
+
websocket(() => "ws://localhost/ws?session_id=s1&cursor=stale"),
|
|
141
|
+
{ types: ["marimo"] },
|
|
142
|
+
);
|
|
143
|
+
const received = collect(events);
|
|
144
|
+
await vi.waitFor(() => expect(FakeSocket.opened).toHaveLength(1));
|
|
145
|
+
const [first] = FakeSocket.opened;
|
|
146
|
+
first.open();
|
|
147
|
+
first.receive(
|
|
148
|
+
'{"event":"marimo","id":"7","data":{"marimo":{"value":NaN}}}',
|
|
149
|
+
);
|
|
150
|
+
first.close();
|
|
151
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
152
|
+
expect(FakeSocket.opened.map((socket) => socket.url))
|
|
153
|
+
.toMatchInlineSnapshot(`
|
|
154
|
+
[
|
|
155
|
+
"ws://localhost/ws?session_id=s1",
|
|
156
|
+
"ws://localhost/ws?session_id=s1&cursor=7",
|
|
157
|
+
]
|
|
158
|
+
`);
|
|
159
|
+
expect(received).toEqual([
|
|
160
|
+
{ event: "marimo", id: "7", data: { marimo: { value: Number.NaN } } },
|
|
161
|
+
]);
|
|
162
|
+
events.close();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("stops on a close the editor treats as final, with its status", async () => {
|
|
166
|
+
vi.stubGlobal("WebSocket", FakeSocket);
|
|
167
|
+
const events = new NotebookEvents(websocket(() => "ws://localhost/ws"));
|
|
168
|
+
await vi.waitFor(() => expect(FakeSocket.opened).toHaveLength(1));
|
|
169
|
+
FakeSocket.opened[0].open();
|
|
170
|
+
FakeSocket.opened[0].close(3000, "MARIMO_UNAUTHORIZED");
|
|
171
|
+
const error = await events.closed.catch((error: unknown) => error);
|
|
172
|
+
expect(error).toBeInstanceOf(StreamStopped);
|
|
173
|
+
expect((error as StreamStopped).cause).toMatchInlineSnapshot(`
|
|
174
|
+
{
|
|
175
|
+
"code": "KERNEL_DISCONNECTED",
|
|
176
|
+
"reason": "not authorized",
|
|
177
|
+
"state": "CLOSED",
|
|
178
|
+
}
|
|
179
|
+
`);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("passes on a message it cannot read for the stream to reject", async () => {
|
|
183
|
+
vi.stubGlobal("WebSocket", FakeSocket);
|
|
184
|
+
const events = new NotebookEvents(websocket(() => "ws://localhost/ws"));
|
|
185
|
+
await vi.waitFor(() => expect(FakeSocket.opened).toHaveLength(1));
|
|
186
|
+
FakeSocket.opened[0].open();
|
|
187
|
+
FakeSocket.opened[0].receive("{broken");
|
|
188
|
+
await expect(events.closed).rejects.toThrow("not an event");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("retries a socket that never opens", async () => {
|
|
192
|
+
vi.useFakeTimers();
|
|
193
|
+
vi.stubGlobal("WebSocket", FakeSocket);
|
|
194
|
+
const events = new NotebookEvents(websocket(() => "ws://localhost/ws"));
|
|
195
|
+
await vi.waitFor(() => expect(FakeSocket.opened).toHaveLength(1));
|
|
196
|
+
await vi.advanceTimersByTimeAsync(10_000 + 2000);
|
|
197
|
+
expect(FakeSocket.opened).toHaveLength(2);
|
|
198
|
+
events.close();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("sessionSse", () => {
|
|
203
|
+
it("sends the runtime's headers and the cursor with each request", async () => {
|
|
204
|
+
vi.useFakeTimers();
|
|
205
|
+
const first = body();
|
|
206
|
+
const fetch = vi.fn(
|
|
207
|
+
async (_input: RequestInfo | URL, _init?: RequestInit) => first.response,
|
|
208
|
+
);
|
|
209
|
+
vi.stubGlobal("fetch", fetch);
|
|
210
|
+
let token = "a";
|
|
211
|
+
const events = new NotebookEvents(
|
|
212
|
+
sessionSse(
|
|
213
|
+
() => "http://localhost/sse?session_id=s1",
|
|
214
|
+
() => ({ "Marimo-Server-Token": token }),
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
first.push("id: 9\nevent: ready\ndata: {}\n\n");
|
|
218
|
+
await vi.waitFor(() => expect(events.cursor).toBe("9"));
|
|
219
|
+
token = "b";
|
|
220
|
+
first.end();
|
|
221
|
+
fetch.mockImplementation(async () => body().response);
|
|
222
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
223
|
+
const requests = fetch.mock.calls.map(
|
|
224
|
+
([input, init]) => new Request(input, init),
|
|
225
|
+
);
|
|
226
|
+
expect(
|
|
227
|
+
requests.map((request) => ({
|
|
228
|
+
url: request.url,
|
|
229
|
+
token: request.headers.get("Marimo-Server-Token"),
|
|
230
|
+
cursor: request.headers.get("Last-Event-ID"),
|
|
231
|
+
})),
|
|
232
|
+
).toMatchInlineSnapshot(`
|
|
233
|
+
[
|
|
234
|
+
{
|
|
235
|
+
"cursor": null,
|
|
236
|
+
"token": "a",
|
|
237
|
+
"url": "http://localhost/sse?session_id=s1",
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
"cursor": "9",
|
|
241
|
+
"token": "b",
|
|
242
|
+
"url": "http://localhost/sse?session_id=s1",
|
|
243
|
+
},
|
|
244
|
+
]
|
|
245
|
+
`);
|
|
246
|
+
events.close();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("stops on a session close frame the editor treats as final", async () => {
|
|
250
|
+
const stream = body();
|
|
251
|
+
vi.stubGlobal("fetch", async () => stream.response);
|
|
252
|
+
const events = new NotebookEvents(
|
|
253
|
+
sessionSse(
|
|
254
|
+
() => "http://localhost/sse",
|
|
255
|
+
() => ({}),
|
|
256
|
+
),
|
|
257
|
+
);
|
|
258
|
+
stream.push(
|
|
259
|
+
'event: close\ndata: {"code":1000,"reason":"MARIMO_SHUTDOWN"}\n\n',
|
|
260
|
+
);
|
|
261
|
+
const error = await events.closed.catch((error: unknown) => error);
|
|
262
|
+
expect((error as StreamStopped).cause).toMatchInlineSnapshot(`
|
|
263
|
+
{
|
|
264
|
+
"code": "KERNEL_DISCONNECTED",
|
|
265
|
+
"reason": "kernel not found",
|
|
266
|
+
"state": "CLOSED",
|
|
267
|
+
}
|
|
268
|
+
`);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it.each([
|
|
272
|
+
["NaN", Number.NaN],
|
|
273
|
+
["Infinity", Number.POSITIVE_INFINITY],
|
|
274
|
+
["-Infinity", Number.NEGATIVE_INFINITY],
|
|
275
|
+
['{"$bigint":"9007199254740993"}', BigInt("9007199254740993")],
|
|
276
|
+
])("reads %s with marimo's parser", async (encoded, value) => {
|
|
277
|
+
const stream = body();
|
|
278
|
+
vi.stubGlobal("fetch", async () => stream.response);
|
|
279
|
+
const events = new NotebookEvents(
|
|
280
|
+
sessionSse(
|
|
281
|
+
() => "http://localhost/sse",
|
|
282
|
+
() => ({}),
|
|
283
|
+
),
|
|
284
|
+
{ types: ["marimo"] },
|
|
285
|
+
);
|
|
286
|
+
const received = collect(events);
|
|
287
|
+
onTestFinished(() => events.close());
|
|
288
|
+
stream.push(`event: marimo\ndata: {"marimo":{"value":${encoded}}}\n\n`);
|
|
289
|
+
await vi.waitFor(() => expect(received).toHaveLength(1));
|
|
290
|
+
expect(received).toEqual([
|
|
291
|
+
{ event: "marimo", data: { marimo: { value } } },
|
|
292
|
+
]);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe.each(["websocket", "sse"] as const)(
|
|
297
|
+
"%s session identity",
|
|
298
|
+
(transportType) => {
|
|
299
|
+
const base =
|
|
300
|
+
transportType === "websocket"
|
|
301
|
+
? "ws://localhost/ws"
|
|
302
|
+
: "http://localhost/sse";
|
|
303
|
+
|
|
304
|
+
it.each(["automatic", "manual"])(
|
|
305
|
+
"discards the cursor and replica when the session changes on %s reconnect",
|
|
306
|
+
async (recovery) => {
|
|
307
|
+
vi.useFakeTimers();
|
|
308
|
+
let target = `${base}?session_id=s1`;
|
|
309
|
+
const url = vi.fn(() => target);
|
|
310
|
+
const connection = network(transportType, url);
|
|
311
|
+
const store = createStore();
|
|
312
|
+
const { client, connect } = createNotebookClient(store);
|
|
313
|
+
const lifetime = new AbortController();
|
|
314
|
+
onTestFinished(() => lifetime.abort());
|
|
315
|
+
connect({
|
|
316
|
+
transport: connection.transport,
|
|
317
|
+
signal: lifetime.signal,
|
|
318
|
+
checkHealth: async () => true,
|
|
319
|
+
onNotification: vi.fn(),
|
|
320
|
+
});
|
|
321
|
+
const first = await connection.opened();
|
|
322
|
+
const snapshot = fixtureMessages("notebook-events.sse");
|
|
323
|
+
snapshot.forEach(first.receive);
|
|
324
|
+
await vi.waitFor(() =>
|
|
325
|
+
expect(store.get(client.notebookAtom)?.id).toBe("n1"),
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
target = `${base}?session_id=s2`;
|
|
329
|
+
if (recovery === "automatic") {
|
|
330
|
+
first.drop();
|
|
331
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
332
|
+
} else {
|
|
333
|
+
first.stop();
|
|
334
|
+
await vi.waitUntil(
|
|
335
|
+
() =>
|
|
336
|
+
store.get(client.connectionAtom).state === ConnectionState.CLOSED,
|
|
337
|
+
);
|
|
338
|
+
await client.reconnect();
|
|
339
|
+
}
|
|
340
|
+
const second = await connection.opened();
|
|
341
|
+
expect({
|
|
342
|
+
cursor: second.cursor,
|
|
343
|
+
notebook: store.get(client.notebookAtom),
|
|
344
|
+
document: store.get(client.documentAtom),
|
|
345
|
+
attachments: store.get(client.attachmentsAtom),
|
|
346
|
+
}).toEqual({
|
|
347
|
+
cursor: null,
|
|
348
|
+
notebook: null,
|
|
349
|
+
document: null,
|
|
350
|
+
attachments: [],
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// A second drop before any records must not resurrect the library's old cursor.
|
|
354
|
+
second.drop();
|
|
355
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
356
|
+
const third = await connection.opened();
|
|
357
|
+
expect(third.cursor).toBeNull();
|
|
358
|
+
for (const frame of snapshot) {
|
|
359
|
+
third.receive({
|
|
360
|
+
...frame,
|
|
361
|
+
data: JSON.parse(
|
|
362
|
+
JSON.stringify(frame.data).replaceAll('"n1"', '"n2"'),
|
|
363
|
+
),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
await vi.waitFor(() =>
|
|
367
|
+
expect(store.get(client.notebookAtom)?.id).toBe("n2"),
|
|
368
|
+
);
|
|
369
|
+
expect(store.get(client.documentAtom)?.revision.notebook_id).toBe("n2");
|
|
370
|
+
expect(url).toHaveBeenCalledTimes(3);
|
|
371
|
+
},
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
it("keeps the replica and cursor when only authentication and query ordering change", async () => {
|
|
375
|
+
vi.useFakeTimers();
|
|
376
|
+
let target = `${base}?session_id=s1&file=a.py&access_token=a`;
|
|
377
|
+
const connection = network(transportType, () => target);
|
|
378
|
+
const store = createStore();
|
|
379
|
+
const { client, connect } = createNotebookClient(store);
|
|
380
|
+
const lifetime = new AbortController();
|
|
381
|
+
onTestFinished(() => lifetime.abort());
|
|
382
|
+
connect({
|
|
383
|
+
transport: connection.transport,
|
|
384
|
+
signal: lifetime.signal,
|
|
385
|
+
checkHealth: async () => true,
|
|
386
|
+
onNotification: vi.fn(),
|
|
387
|
+
});
|
|
388
|
+
const first = await connection.opened();
|
|
389
|
+
fixtureMessages("notebook-events.sse").forEach(first.receive);
|
|
390
|
+
await vi.waitFor(() =>
|
|
391
|
+
expect(store.get(client.notebookAtom)?.id).toBe("n1"),
|
|
392
|
+
);
|
|
393
|
+
const document = store.get(client.documentAtom);
|
|
394
|
+
const attachments = store.get(client.attachmentsAtom);
|
|
395
|
+
|
|
396
|
+
target = `${base}?access_token=b&file=a.py&session_id=s1`;
|
|
397
|
+
first.drop();
|
|
398
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
399
|
+
const second = await connection.opened();
|
|
400
|
+
expect(second.cursor).toBe("97");
|
|
401
|
+
expect(store.get(client.documentAtom)).toBe(document);
|
|
402
|
+
second.receive({ event: "ready", id: "100", data: {} });
|
|
403
|
+
await vi.waitFor(() =>
|
|
404
|
+
expect(store.get(client.notebookAtom)?.id).toBe("n1"),
|
|
405
|
+
);
|
|
406
|
+
expect(store.get(client.attachmentsAtom)).toEqual(attachments);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("does not open a connection after cancellation during URL resolution", async () => {
|
|
410
|
+
vi.useFakeTimers();
|
|
411
|
+
const target = new Deferred<string>();
|
|
412
|
+
const url = vi.fn(() => target.promise);
|
|
413
|
+
const connection = network(transportType, url);
|
|
414
|
+
const events = new NotebookEvents(connection.transport);
|
|
415
|
+
onTestFinished(() => events.close());
|
|
416
|
+
const received = collect(events);
|
|
417
|
+
await vi.waitFor(() => expect(url).toHaveBeenCalledOnce());
|
|
418
|
+
events.close();
|
|
419
|
+
target.resolve(base);
|
|
420
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
421
|
+
expect({
|
|
422
|
+
opened: connection.count(),
|
|
423
|
+
events: received,
|
|
424
|
+
status: events.status,
|
|
425
|
+
}).toEqual({ opened: 0, events: [], status: "closed" });
|
|
426
|
+
});
|
|
427
|
+
},
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
describe("pyodide", () => {
|
|
431
|
+
it("delivers worker notifications as marimo events and host events as they are", async () => {
|
|
432
|
+
let consumer: ((message: MessageEvent<string>) => void) | undefined;
|
|
433
|
+
const events = new NotebookEvents(
|
|
434
|
+
pyodide((next) => {
|
|
435
|
+
consumer = next;
|
|
436
|
+
}),
|
|
437
|
+
{ types: ["notebook", "marimo"] },
|
|
438
|
+
);
|
|
439
|
+
const received = collect(events);
|
|
440
|
+
await vi.waitFor(() => expect(consumer).toBeDefined());
|
|
441
|
+
const send = (data: string) =>
|
|
442
|
+
consumer?.(new MessageEvent("message", { data }));
|
|
443
|
+
send('{"data":{"op":"kernel-ready"}}');
|
|
444
|
+
send('{"event":"marimo","id":"3","data":{"marimo":{"op":"reconnected"}}}');
|
|
445
|
+
expect(received).toMatchInlineSnapshot(`
|
|
446
|
+
[
|
|
447
|
+
{
|
|
448
|
+
"data": {
|
|
449
|
+
"marimo": {
|
|
450
|
+
"op": "kernel-ready",
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
"event": "marimo",
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
"data": {
|
|
457
|
+
"marimo": {
|
|
458
|
+
"op": "reconnected",
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
"event": "marimo",
|
|
462
|
+
"id": "3",
|
|
463
|
+
},
|
|
464
|
+
]
|
|
465
|
+
`);
|
|
466
|
+
events.close();
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe("createNotebookTransport", () => {
|
|
471
|
+
const options = {
|
|
472
|
+
static: false,
|
|
473
|
+
transportType: "websocket" as const,
|
|
474
|
+
url: () => "ws://localhost/ws",
|
|
475
|
+
headers: () => ({}),
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
it.each([
|
|
479
|
+
["a static notebook", { ...options, static: true }, "idle"],
|
|
480
|
+
["WASM", options, "wasm"],
|
|
481
|
+
["SSE", { ...options, transportType: "sse" as const }, "fetch"],
|
|
482
|
+
["the default", options, "socket"],
|
|
483
|
+
] as const)("follows %s", async (_name, chosen, expected) => {
|
|
484
|
+
wasm.enabled = expected === "wasm";
|
|
485
|
+
const fetch = vi.fn(() => new Promise<Response>(() => undefined));
|
|
486
|
+
vi.stubGlobal("fetch", fetch);
|
|
487
|
+
vi.stubGlobal("WebSocket", FakeSocket);
|
|
488
|
+
const events = new NotebookEvents(createNotebookTransport(chosen));
|
|
489
|
+
await vi.waitFor(() =>
|
|
490
|
+
expect(
|
|
491
|
+
{
|
|
492
|
+
idle:
|
|
493
|
+
events.status === "connected" && !wasm.consumer.mock.calls.length,
|
|
494
|
+
wasm: wasm.consumer.mock.calls.length === 1,
|
|
495
|
+
fetch: fetch.mock.calls.length === 1,
|
|
496
|
+
socket: FakeSocket.opened.length === 1,
|
|
497
|
+
}[expected],
|
|
498
|
+
).toBe(true),
|
|
499
|
+
);
|
|
500
|
+
events.close();
|
|
501
|
+
});
|
|
502
|
+
});
|