@marimo-team/frontend 0.25.3-dev80 → 0.25.3-dev82
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-Di0cSFhC.js} +32 -11
- 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-CGNUQdgW.js → command-palette-Dg_xstl0.js} +1 -1
- package/dist/assets/{common-DeeLgjWQ.js → common-D7VAS6rd.js} +1 -1
- 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-B3T9kSTs.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-CBftLPze.js → edit-page-BPMo_x-T.js} +4 -4
- 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-B-5H1fEt.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-3qsPTb4v.js → home-page-DwWvBB0R.js} +1 -1
- package/dist/assets/{hooks-BrFYiTJS.js → hooks-CHeUZHPp.js} +1 -1
- package/dist/assets/{index-D0IWODMU.js → index-DckSn2w6.js} +8 -8
- package/dist/assets/{layout-CGC8ajpe.js → layout-_lp06T0C.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-DU-SaRHy.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-eLftBwOe.js} +1 -1
- package/dist/assets/{run-page-CLP1s8uX.js → run-page-mURyATDJ.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-B_gEnoqj.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-DDohxo24.js → tracing-AsNGuDsh.js} +1 -1
- package/dist/assets/{tracing-panel-DYhGDAo5.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-DAcQhse_.js → useCellActionButton-CmQ-OcPa.js} +1 -1
- package/dist/assets/{useDeleteCell-BV4VemCv.js → useDeleteCell-CXgOCDqY.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-PtXIVh53.js → useDependencyPanelTab-Ch6gm7uM.js} +1 -1
- package/dist/assets/{useInstallPackage-C7neMPwh.js → useInstallPackage-CYQoMdMQ.js} +1 -1
- package/dist/assets/{useNotebookActions-DZBB98fP.js → useNotebookActions-DGShHZ_1.js} +1 -1
- 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 +24 -24
- package/package.json +1 -1
- package/src/core/host/AGENTS.md +6 -5
- package/src/core/host/__tests__/notebook-client.test.ts +368 -350
- package/src/core/host/connection.ts +1 -40
- package/src/core/host/notebook-client.ts +129 -186
- package/src/core/wasm/bridge.ts +0 -8
- package/src/core/websocket/__tests__/connection-state.test.ts +0 -12
- 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/connection-state.ts +0 -10
- 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/dist/assets/config-DzdxZb17.js +0 -1
- package/dist/assets/feedback-button-DknbRCN_.js +0 -1
- package/dist/assets/panels-D1i80TGn.js +0 -107
- package/dist/assets/state-BRYi026f.js +0 -3
- package/dist/assets/ws-BXWY541V.js +0 -22
- package/src/core/host/__tests__/events.test.ts +0 -49
- package/src/core/host/events.ts +0 -68
- package/src/core/websocket/__tests__/createConnectionTransport.test.ts +0 -49
- package/src/core/websocket/transports/__tests__/basic.test.ts +0 -275
- package/src/core/websocket/transports/__tests__/sse.test.ts +0 -352
- package/src/core/websocket/transports/__tests__/transport.test.ts +0 -308
- package/src/core/websocket/transports/__tests__/ws.test.ts +0 -125
- package/src/core/websocket/transports/basic.ts +0 -102
- package/src/core/websocket/transports/create.ts +0 -31
- package/src/core/websocket/transports/sse.ts +0 -320
- package/src/core/websocket/transports/transport.ts +0 -50
- package/src/core/websocket/transports/ws.ts +0 -98
|
@@ -1,352 +0,0 @@
|
|
|
1
|
-
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
|
|
3
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { SseTransport } from "../sse";
|
|
5
|
-
import { MAX_RETRIES, TRANSPORT_EXHAUSTED_REASON } from "../ws";
|
|
6
|
-
|
|
7
|
-
const encoder = new TextEncoder();
|
|
8
|
-
|
|
9
|
-
function createControllableStream() {
|
|
10
|
-
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
11
|
-
const stream = new ReadableStream<Uint8Array>({
|
|
12
|
-
start(c) {
|
|
13
|
-
controller = c;
|
|
14
|
-
},
|
|
15
|
-
});
|
|
16
|
-
return {
|
|
17
|
-
stream,
|
|
18
|
-
push: (text: string) => controller.enqueue(encoder.encode(text)),
|
|
19
|
-
end: () => controller.close(),
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function sseResponse(body: ReadableStream<Uint8Array> | null, status = 200) {
|
|
24
|
-
return {
|
|
25
|
-
ok: status >= 200 && status < 300,
|
|
26
|
-
status,
|
|
27
|
-
body,
|
|
28
|
-
} as Response;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
describe("SseTransport", () => {
|
|
32
|
-
let transport: SseTransport;
|
|
33
|
-
let fetchMock: ReturnType<typeof vi.fn>;
|
|
34
|
-
let opens: Event[];
|
|
35
|
-
let messages: MessageEvent[];
|
|
36
|
-
let closes: CloseEvent[];
|
|
37
|
-
let errors: Event[];
|
|
38
|
-
|
|
39
|
-
beforeEach(() => {
|
|
40
|
-
fetchMock = vi.fn();
|
|
41
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
42
|
-
transport = new SseTransport(() => "http://example.invalid/sse");
|
|
43
|
-
opens = [];
|
|
44
|
-
messages = [];
|
|
45
|
-
closes = [];
|
|
46
|
-
errors = [];
|
|
47
|
-
transport.addEventListener("open", (e) => opens.push(e));
|
|
48
|
-
transport.addEventListener("message", (e) => messages.push(e));
|
|
49
|
-
transport.addEventListener("close", (e) => closes.push(e));
|
|
50
|
-
transport.addEventListener("error", (e) => errors.push(e));
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
afterEach(() => {
|
|
54
|
-
transport.close();
|
|
55
|
-
vi.unstubAllGlobals();
|
|
56
|
-
vi.useRealTimers();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("starts closed and connects on reconnect()", async () => {
|
|
60
|
-
expect(transport.readyState).toBe(WebSocket.CLOSED);
|
|
61
|
-
const { stream } = createControllableStream();
|
|
62
|
-
fetchMock.mockResolvedValueOnce(sseResponse(stream));
|
|
63
|
-
|
|
64
|
-
transport.reconnect();
|
|
65
|
-
expect(transport.readyState).toBe(WebSocket.CONNECTING);
|
|
66
|
-
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
67
|
-
expect(transport.readyState).toBe(WebSocket.OPEN);
|
|
68
|
-
expect(fetchMock).toHaveBeenCalledWith(
|
|
69
|
-
"http://example.invalid/sse",
|
|
70
|
-
expect.objectContaining({
|
|
71
|
-
headers: { Accept: "text/event-stream" },
|
|
72
|
-
}),
|
|
73
|
-
);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("dispatches message events, joining multi-line data", async () => {
|
|
77
|
-
const { stream, push } = createControllableStream();
|
|
78
|
-
fetchMock.mockResolvedValueOnce(sseResponse(stream));
|
|
79
|
-
transport.reconnect();
|
|
80
|
-
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
81
|
-
|
|
82
|
-
push('data: {"op": "alert"}\n\n');
|
|
83
|
-
await vi.waitFor(() => expect(messages).toHaveLength(1));
|
|
84
|
-
expect(messages[0].data).toBe('{"op": "alert"}');
|
|
85
|
-
|
|
86
|
-
// Multi-line data and records split across chunks
|
|
87
|
-
push("data: line1\nda");
|
|
88
|
-
push("ta: line2\n\n");
|
|
89
|
-
await vi.waitFor(() => expect(messages).toHaveLength(2));
|
|
90
|
-
expect(messages[1].data).toBe("line1\nline2");
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it("preserves host event names and cursors without carrying IDs into snapshots", async () => {
|
|
94
|
-
const { stream, push } = createControllableStream();
|
|
95
|
-
fetchMock.mockResolvedValueOnce(sseResponse(stream));
|
|
96
|
-
transport.reconnect();
|
|
97
|
-
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
98
|
-
|
|
99
|
-
push("id: cursor-1\nevent: rea");
|
|
100
|
-
push("dy\ndata: {}\n\n: keep-alive\n\nevent: reset\ndata: {}\n\n");
|
|
101
|
-
push('event: marimo\ndata: {"marimo":\ndata: {"op":"reconnected"}}\n\n');
|
|
102
|
-
await vi.waitFor(() => expect(messages).toHaveLength(3));
|
|
103
|
-
expect(messages.map((message) => JSON.parse(message.data))).toEqual([
|
|
104
|
-
{ event: "ready", id: "cursor-1", data: {} },
|
|
105
|
-
{ event: "reset", data: {} },
|
|
106
|
-
{ event: "marimo", data: { marimo: { op: "reconnected" } } },
|
|
107
|
-
]);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("ignores keep-alive comments", async () => {
|
|
111
|
-
const { stream, push } = createControllableStream();
|
|
112
|
-
fetchMock.mockResolvedValueOnce(sseResponse(stream));
|
|
113
|
-
transport.reconnect();
|
|
114
|
-
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
115
|
-
|
|
116
|
-
push(": keep-alive\n\n");
|
|
117
|
-
push("data: after\n\n");
|
|
118
|
-
await vi.waitFor(() => expect(messages).toHaveLength(1));
|
|
119
|
-
expect(messages[0].data).toBe("after");
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("surfaces a server close event with its code and reason", async () => {
|
|
123
|
-
const { stream, push } = createControllableStream();
|
|
124
|
-
fetchMock.mockResolvedValueOnce(sseResponse(stream));
|
|
125
|
-
transport.reconnect();
|
|
126
|
-
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
127
|
-
|
|
128
|
-
transport.addEventListener("close", () => transport.close());
|
|
129
|
-
push('event: close\ndata: {"code": 1000, "reason": "MARIMO_SHUTDOWN"}\n\n');
|
|
130
|
-
await vi.waitFor(() => expect(closes).toHaveLength(1));
|
|
131
|
-
expect(closes[0].code).toBe(1000);
|
|
132
|
-
expect(closes[0].reason).toBe("MARIMO_SHUTDOWN");
|
|
133
|
-
expect(closes[0].wasClean).toBe(true);
|
|
134
|
-
expect(transport.readyState).toBe(WebSocket.CLOSED);
|
|
135
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("emits an empty-reason close and retries when the stream ends", async () => {
|
|
139
|
-
vi.useFakeTimers();
|
|
140
|
-
const first = createControllableStream();
|
|
141
|
-
fetchMock.mockResolvedValueOnce(sseResponse(first.stream));
|
|
142
|
-
transport.reconnect();
|
|
143
|
-
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
144
|
-
|
|
145
|
-
first.end();
|
|
146
|
-
await vi.waitFor(() => expect(closes).toHaveLength(1));
|
|
147
|
-
expect(closes[0].reason).toBe("");
|
|
148
|
-
expect(closes[0].wasClean).toBe(false);
|
|
149
|
-
|
|
150
|
-
// A retry is scheduled and reconnects
|
|
151
|
-
const second = createControllableStream();
|
|
152
|
-
fetchMock.mockResolvedValueOnce(sseResponse(second.stream));
|
|
153
|
-
await vi.advanceTimersByTimeAsync(10_000);
|
|
154
|
-
await vi.waitFor(() => expect(opens).toHaveLength(2));
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("does not stack its own retry when the consumer reconnects in onClose", async () => {
|
|
158
|
-
vi.useFakeTimers();
|
|
159
|
-
const first = createControllableStream();
|
|
160
|
-
const second = createControllableStream();
|
|
161
|
-
fetchMock
|
|
162
|
-
.mockResolvedValueOnce(sseResponse(first.stream))
|
|
163
|
-
.mockResolvedValueOnce(sseResponse(second.stream));
|
|
164
|
-
// Reconnect synchronously from the close handler.
|
|
165
|
-
transport.addEventListener("close", () => transport.reconnect());
|
|
166
|
-
|
|
167
|
-
transport.reconnect();
|
|
168
|
-
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
169
|
-
|
|
170
|
-
first.end();
|
|
171
|
-
await vi.waitFor(() => expect(opens).toHaveLength(2));
|
|
172
|
-
|
|
173
|
-
// The superseded run must not schedule a second connection on top of
|
|
174
|
-
// the consumer's reconnect.
|
|
175
|
-
await vi.advanceTimersByTimeAsync(60_000);
|
|
176
|
-
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
177
|
-
|
|
178
|
-
// The consumer-driven connection is healthy
|
|
179
|
-
second.push("data: alive\n\n");
|
|
180
|
-
await vi.waitFor(() => expect(messages).toHaveLength(1));
|
|
181
|
-
expect(transport.readyState).toBe(WebSocket.OPEN);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it("exhausts the retry budget when the server closes every connection", async () => {
|
|
185
|
-
vi.useFakeTimers();
|
|
186
|
-
// A server that accepts, immediately sends a close event, and never
|
|
187
|
-
// delivers a message must not be retried forever.
|
|
188
|
-
fetchMock.mockImplementation(() => {
|
|
189
|
-
const { stream, push } = createControllableStream();
|
|
190
|
-
push('event: close\ndata: {"code": 3000, "reason": "MARIMO_X"}\n\n');
|
|
191
|
-
return Promise.resolve(sseResponse(stream));
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
transport.reconnect();
|
|
195
|
-
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
196
|
-
await vi.advanceTimersByTimeAsync(15_000);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
expect(closes.length).toBe(MAX_RETRIES);
|
|
200
|
-
expect(closes.at(-1)?.reason).toBe(TRANSPORT_EXHAUSTED_REASON);
|
|
201
|
-
const fetchCallsWhenExhausted = fetchMock.mock.calls.length;
|
|
202
|
-
await vi.advanceTimersByTimeAsync(60_000);
|
|
203
|
-
expect(fetchMock.mock.calls.length).toBe(fetchCallsWhenExhausted);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it("a delivered message resets the retry budget", async () => {
|
|
207
|
-
vi.useFakeTimers();
|
|
208
|
-
const first = createControllableStream();
|
|
209
|
-
fetchMock.mockResolvedValueOnce(sseResponse(first.stream));
|
|
210
|
-
transport.reconnect();
|
|
211
|
-
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
212
|
-
first.push("data: healthy\n\n");
|
|
213
|
-
await vi.waitFor(() => expect(messages).toHaveLength(1));
|
|
214
|
-
|
|
215
|
-
first.end();
|
|
216
|
-
await vi.waitFor(() => expect(closes).toHaveLength(1));
|
|
217
|
-
// Budget was reset by the message, so this close is retry #1, not
|
|
218
|
-
// an exhaustion.
|
|
219
|
-
expect(closes[0].reason).toBe("");
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
it("gives up with TRANSPORT_EXHAUSTED_REASON after MAX_RETRIES failures", async () => {
|
|
223
|
-
vi.useFakeTimers();
|
|
224
|
-
fetchMock.mockRejectedValue(new Error("connection refused"));
|
|
225
|
-
|
|
226
|
-
transport.reconnect();
|
|
227
|
-
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
228
|
-
await vi.advanceTimersByTimeAsync(15_000);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
expect(closes.length).toBe(MAX_RETRIES);
|
|
232
|
-
expect(closes.at(-1)?.reason).toBe(TRANSPORT_EXHAUSTED_REASON);
|
|
233
|
-
expect(errors.length).toBe(MAX_RETRIES);
|
|
234
|
-
const fetchCallsWhenExhausted = fetchMock.mock.calls.length;
|
|
235
|
-
|
|
236
|
-
// No further retries once exhausted
|
|
237
|
-
await vi.advanceTimersByTimeAsync(60_000);
|
|
238
|
-
expect(fetchMock.mock.calls.length).toBe(fetchCallsWhenExhausted);
|
|
239
|
-
|
|
240
|
-
// A manual reconnect() resets the budget
|
|
241
|
-
const { stream } = createControllableStream();
|
|
242
|
-
fetchMock.mockResolvedValueOnce(sseResponse(stream));
|
|
243
|
-
transport.reconnect();
|
|
244
|
-
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it("close() aborts the connection and cancels retries", async () => {
|
|
248
|
-
vi.useFakeTimers();
|
|
249
|
-
const { stream } = createControllableStream();
|
|
250
|
-
fetchMock.mockResolvedValueOnce(sseResponse(stream));
|
|
251
|
-
transport.reconnect();
|
|
252
|
-
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
253
|
-
|
|
254
|
-
transport.close();
|
|
255
|
-
expect(transport.readyState).toBe(WebSocket.CLOSED);
|
|
256
|
-
// No close event is dispatched for a user-initiated close, and no
|
|
257
|
-
// retry is scheduled.
|
|
258
|
-
await vi.advanceTimersByTimeAsync(60_000);
|
|
259
|
-
expect(closes).toHaveLength(0);
|
|
260
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it("treats a non-2xx response as a connection failure", async () => {
|
|
264
|
-
vi.useFakeTimers();
|
|
265
|
-
fetchMock.mockResolvedValueOnce(sseResponse(null, 500));
|
|
266
|
-
transport.reconnect();
|
|
267
|
-
|
|
268
|
-
await vi.waitFor(() => expect(closes).toHaveLength(1));
|
|
269
|
-
expect(opens).toHaveLength(0);
|
|
270
|
-
expect(errors).toHaveLength(1);
|
|
271
|
-
expect(closes[0].reason).toBe("");
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
it("sends provided headers (auth) with the request", async () => {
|
|
275
|
-
const withHeaders = new SseTransport(
|
|
276
|
-
() => "http://example.invalid/sse",
|
|
277
|
-
() => ({ Authorization: "Bearer token123" }),
|
|
278
|
-
);
|
|
279
|
-
const { stream } = createControllableStream();
|
|
280
|
-
fetchMock.mockResolvedValueOnce(sseResponse(stream));
|
|
281
|
-
withHeaders.reconnect();
|
|
282
|
-
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
|
283
|
-
withHeaders.close();
|
|
284
|
-
|
|
285
|
-
expect(fetchMock).toHaveBeenCalledWith(
|
|
286
|
-
"http://example.invalid/sse",
|
|
287
|
-
expect.objectContaining({
|
|
288
|
-
headers: {
|
|
289
|
-
Authorization: "Bearer token123",
|
|
290
|
-
Accept: "text/event-stream",
|
|
291
|
-
},
|
|
292
|
-
}),
|
|
293
|
-
);
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
it("aborts a connection attempt that exceeds the timeout", async () => {
|
|
297
|
-
vi.useFakeTimers();
|
|
298
|
-
// fetch that only settles when aborted
|
|
299
|
-
fetchMock.mockImplementation(
|
|
300
|
-
(_url: string, init: RequestInit) =>
|
|
301
|
-
new Promise((_, reject) => {
|
|
302
|
-
init.signal?.addEventListener("abort", () =>
|
|
303
|
-
reject(new Error("aborted")),
|
|
304
|
-
);
|
|
305
|
-
}),
|
|
306
|
-
);
|
|
307
|
-
|
|
308
|
-
transport.reconnect();
|
|
309
|
-
await vi.advanceTimersByTimeAsync(11_000);
|
|
310
|
-
expect(errors).toHaveLength(1);
|
|
311
|
-
expect(closes).toHaveLength(1);
|
|
312
|
-
expect(closes[0].reason).toBe("");
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
it("reconnect() mid-stream supersedes the old connection silently", async () => {
|
|
316
|
-
const first = createControllableStream();
|
|
317
|
-
const second = createControllableStream();
|
|
318
|
-
fetchMock
|
|
319
|
-
.mockResolvedValueOnce(sseResponse(first.stream))
|
|
320
|
-
.mockResolvedValueOnce(sseResponse(second.stream));
|
|
321
|
-
|
|
322
|
-
transport.reconnect();
|
|
323
|
-
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
324
|
-
|
|
325
|
-
transport.reconnect();
|
|
326
|
-
await vi.waitFor(() => expect(opens).toHaveLength(2));
|
|
327
|
-
|
|
328
|
-
// The aborted first connection must not surface close/error events
|
|
329
|
-
second.push("data: alive\n\n");
|
|
330
|
-
await vi.waitFor(() => expect(messages).toHaveLength(1));
|
|
331
|
-
expect(closes).toHaveLength(0);
|
|
332
|
-
expect(errors).toHaveLength(0);
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
it("send() is a no-op", () => {
|
|
336
|
-
expect(() => transport.send("data")).not.toThrow();
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
it("dedupes repeated addEventListener calls", async () => {
|
|
340
|
-
const { stream, push } = createControllableStream();
|
|
341
|
-
fetchMock.mockResolvedValueOnce(sseResponse(stream));
|
|
342
|
-
const onMessage = (e: MessageEvent) => messages.push(e);
|
|
343
|
-
transport.addEventListener("message", onMessage);
|
|
344
|
-
transport.addEventListener("message", onMessage);
|
|
345
|
-
transport.reconnect();
|
|
346
|
-
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
347
|
-
|
|
348
|
-
push("data: once\n\n");
|
|
349
|
-
// The beforeEach listener plus the deduped listener = 2 events
|
|
350
|
-
await vi.waitFor(() => expect(messages).toHaveLength(2));
|
|
351
|
-
});
|
|
352
|
-
});
|
|
@@ -1,308 +0,0 @@
|
|
|
1
|
-
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { ConnectionSubscriptions } from "../transport";
|
|
5
|
-
|
|
6
|
-
describe("ConnectionSubscriptions", () => {
|
|
7
|
-
describe("addSubscription", () => {
|
|
8
|
-
it("should add a subscription for an event", () => {
|
|
9
|
-
const subscriptions = new ConnectionSubscriptions();
|
|
10
|
-
const callback = vi.fn();
|
|
11
|
-
|
|
12
|
-
subscriptions.addSubscription("open", callback);
|
|
13
|
-
|
|
14
|
-
// Verify by notifying
|
|
15
|
-
subscriptions.notify("open", new Event("open"));
|
|
16
|
-
expect(callback).toHaveBeenCalledTimes(1);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it("should add multiple subscriptions for the same event", () => {
|
|
20
|
-
const subscriptions = new ConnectionSubscriptions();
|
|
21
|
-
const callback1 = vi.fn();
|
|
22
|
-
const callback2 = vi.fn();
|
|
23
|
-
const callback3 = vi.fn();
|
|
24
|
-
|
|
25
|
-
subscriptions.addSubscription("message", callback1);
|
|
26
|
-
subscriptions.addSubscription("message", callback2);
|
|
27
|
-
subscriptions.addSubscription("message", callback3);
|
|
28
|
-
|
|
29
|
-
const messageEvent = new MessageEvent("message", { data: "test" });
|
|
30
|
-
subscriptions.notify("message", messageEvent);
|
|
31
|
-
|
|
32
|
-
expect(callback1).toHaveBeenCalledWith(messageEvent);
|
|
33
|
-
expect(callback2).toHaveBeenCalledWith(messageEvent);
|
|
34
|
-
expect(callback3).toHaveBeenCalledWith(messageEvent);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("should add subscriptions for different event types", () => {
|
|
38
|
-
const subscriptions = new ConnectionSubscriptions();
|
|
39
|
-
const openCallback = vi.fn();
|
|
40
|
-
const closeCallback = vi.fn();
|
|
41
|
-
const messageCallback = vi.fn();
|
|
42
|
-
|
|
43
|
-
subscriptions.addSubscription("open", openCallback);
|
|
44
|
-
subscriptions.addSubscription("close", closeCallback);
|
|
45
|
-
subscriptions.addSubscription("message", messageCallback);
|
|
46
|
-
|
|
47
|
-
subscriptions.notify("open", new Event("open"));
|
|
48
|
-
expect(openCallback).toHaveBeenCalledTimes(1);
|
|
49
|
-
expect(closeCallback).not.toHaveBeenCalled();
|
|
50
|
-
expect(messageCallback).not.toHaveBeenCalled();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("should not add duplicate subscriptions for the same callback", () => {
|
|
54
|
-
const subscriptions = new ConnectionSubscriptions();
|
|
55
|
-
const callback = vi.fn();
|
|
56
|
-
|
|
57
|
-
subscriptions.addSubscription("close", callback);
|
|
58
|
-
subscriptions.addSubscription("close", callback);
|
|
59
|
-
subscriptions.addSubscription("close", callback);
|
|
60
|
-
|
|
61
|
-
subscriptions.notify("close", new Event("close"));
|
|
62
|
-
|
|
63
|
-
// Set only stores unique callbacks, so should be called once
|
|
64
|
-
expect(callback).toHaveBeenCalledTimes(1);
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
describe("removeSubscription", () => {
|
|
69
|
-
it("should remove a subscription", () => {
|
|
70
|
-
const subscriptions = new ConnectionSubscriptions();
|
|
71
|
-
const callback = vi.fn();
|
|
72
|
-
|
|
73
|
-
subscriptions.addSubscription("message", callback);
|
|
74
|
-
subscriptions.removeSubscription("message", callback);
|
|
75
|
-
|
|
76
|
-
const messageEvent = new MessageEvent("message", { data: "test" });
|
|
77
|
-
subscriptions.notify("message", messageEvent);
|
|
78
|
-
|
|
79
|
-
expect(callback).not.toHaveBeenCalled();
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("should only remove the specific callback", () => {
|
|
83
|
-
const subscriptions = new ConnectionSubscriptions();
|
|
84
|
-
const callback1 = vi.fn();
|
|
85
|
-
const callback2 = vi.fn();
|
|
86
|
-
const callback3 = vi.fn();
|
|
87
|
-
|
|
88
|
-
subscriptions.addSubscription("close", callback1);
|
|
89
|
-
subscriptions.addSubscription("close", callback2);
|
|
90
|
-
subscriptions.addSubscription("close", callback3);
|
|
91
|
-
|
|
92
|
-
subscriptions.removeSubscription("close", callback2);
|
|
93
|
-
|
|
94
|
-
subscriptions.notify("close", new Event("close"));
|
|
95
|
-
|
|
96
|
-
expect(callback1).toHaveBeenCalledTimes(1);
|
|
97
|
-
expect(callback2).not.toHaveBeenCalled();
|
|
98
|
-
expect(callback3).toHaveBeenCalledTimes(1);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("should handle removing subscription from non-existent event type", () => {
|
|
102
|
-
const subscriptions = new ConnectionSubscriptions();
|
|
103
|
-
const callback = vi.fn();
|
|
104
|
-
|
|
105
|
-
subscriptions.addSubscription("open", callback);
|
|
106
|
-
|
|
107
|
-
expect(() =>
|
|
108
|
-
subscriptions.removeSubscription("close", callback),
|
|
109
|
-
).not.toThrow();
|
|
110
|
-
|
|
111
|
-
subscriptions.notify("open", new Event("open"));
|
|
112
|
-
expect(callback).toHaveBeenCalledTimes(1);
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
describe("notify", () => {
|
|
117
|
-
it("should notify all subscribed callbacks for an event", () => {
|
|
118
|
-
const subscriptions = new ConnectionSubscriptions();
|
|
119
|
-
const callback1 = vi.fn();
|
|
120
|
-
const callback2 = vi.fn();
|
|
121
|
-
|
|
122
|
-
subscriptions.addSubscription("open", callback1);
|
|
123
|
-
subscriptions.addSubscription("open", callback2);
|
|
124
|
-
|
|
125
|
-
const openEvent = new Event("open");
|
|
126
|
-
subscriptions.notify("open", openEvent);
|
|
127
|
-
|
|
128
|
-
expect(callback1).toHaveBeenCalledWith(openEvent);
|
|
129
|
-
expect(callback2).toHaveBeenCalledWith(openEvent);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("should pass the correct data to callbacks", () => {
|
|
133
|
-
const subscriptions = new ConnectionSubscriptions();
|
|
134
|
-
const callback = vi.fn();
|
|
135
|
-
|
|
136
|
-
subscriptions.addSubscription("message", callback);
|
|
137
|
-
|
|
138
|
-
const messageEvent = new MessageEvent("message", {
|
|
139
|
-
data: "test data",
|
|
140
|
-
});
|
|
141
|
-
subscriptions.notify("message", messageEvent);
|
|
142
|
-
|
|
143
|
-
expect(callback).toHaveBeenCalledWith(messageEvent);
|
|
144
|
-
expect(callback.mock.calls[0][0].data).toBe("test data");
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it("should not notify callbacks for different event types", () => {
|
|
148
|
-
const subscriptions = new ConnectionSubscriptions();
|
|
149
|
-
const openCallback = vi.fn();
|
|
150
|
-
const closeCallback = vi.fn();
|
|
151
|
-
|
|
152
|
-
subscriptions.addSubscription("open", openCallback);
|
|
153
|
-
subscriptions.addSubscription("close", closeCallback);
|
|
154
|
-
|
|
155
|
-
subscriptions.notify("open", new Event("open"));
|
|
156
|
-
|
|
157
|
-
expect(openCallback).toHaveBeenCalledTimes(1);
|
|
158
|
-
expect(closeCallback).not.toHaveBeenCalled();
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("should handle exceptions in callbacks without affecting other callbacks", () => {
|
|
162
|
-
const subscriptions = new ConnectionSubscriptions();
|
|
163
|
-
const throwingCallback = vi.fn(() => {
|
|
164
|
-
throw new Error("Callback error");
|
|
165
|
-
});
|
|
166
|
-
const normalCallback = vi.fn();
|
|
167
|
-
|
|
168
|
-
subscriptions.addSubscription("message", throwingCallback);
|
|
169
|
-
subscriptions.addSubscription("message", normalCallback);
|
|
170
|
-
|
|
171
|
-
const messageEvent = new MessageEvent("message", { data: "test" });
|
|
172
|
-
|
|
173
|
-
expect(() => subscriptions.notify("message", messageEvent)).toThrow(
|
|
174
|
-
"Callback error",
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
expect(throwingCallback).toHaveBeenCalledWith(messageEvent);
|
|
178
|
-
expect(normalCallback).not.toHaveBeenCalled();
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
describe("Integration scenarios", () => {
|
|
183
|
-
it("should handle complete subscription lifecycle", () => {
|
|
184
|
-
const subscriptions = new ConnectionSubscriptions();
|
|
185
|
-
const callback = vi.fn();
|
|
186
|
-
|
|
187
|
-
subscriptions.addSubscription("open", callback);
|
|
188
|
-
|
|
189
|
-
subscriptions.notify("open", new Event("open"));
|
|
190
|
-
expect(callback).toHaveBeenCalledTimes(1);
|
|
191
|
-
|
|
192
|
-
subscriptions.removeSubscription("open", callback);
|
|
193
|
-
|
|
194
|
-
subscriptions.notify("open", new Event("open"));
|
|
195
|
-
expect(callback).toHaveBeenCalledTimes(1);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("should handle multiple events with multiple callbacks", () => {
|
|
199
|
-
const subscriptions = new ConnectionSubscriptions();
|
|
200
|
-
|
|
201
|
-
const openCallback1 = vi.fn();
|
|
202
|
-
const openCallback2 = vi.fn();
|
|
203
|
-
const closeCallback1 = vi.fn();
|
|
204
|
-
const messageCallback = vi.fn();
|
|
205
|
-
|
|
206
|
-
subscriptions.addSubscription("open", openCallback1);
|
|
207
|
-
subscriptions.addSubscription("open", openCallback2);
|
|
208
|
-
subscriptions.addSubscription("close", closeCallback1);
|
|
209
|
-
subscriptions.addSubscription("message", messageCallback);
|
|
210
|
-
|
|
211
|
-
subscriptions.notify("open", new Event("open"));
|
|
212
|
-
expect(openCallback1).toHaveBeenCalledTimes(1);
|
|
213
|
-
expect(openCallback2).toHaveBeenCalledTimes(1);
|
|
214
|
-
expect(closeCallback1).not.toHaveBeenCalled();
|
|
215
|
-
expect(messageCallback).not.toHaveBeenCalled();
|
|
216
|
-
|
|
217
|
-
const messageEvent = new MessageEvent("message", { data: "test" });
|
|
218
|
-
subscriptions.notify("message", messageEvent);
|
|
219
|
-
expect(openCallback1).toHaveBeenCalledTimes(1);
|
|
220
|
-
expect(openCallback2).toHaveBeenCalledTimes(1);
|
|
221
|
-
expect(closeCallback1).not.toHaveBeenCalled();
|
|
222
|
-
expect(messageCallback).toHaveBeenCalledTimes(1);
|
|
223
|
-
expect(messageCallback).toHaveBeenCalledWith(messageEvent);
|
|
224
|
-
|
|
225
|
-
subscriptions.notify("close", new Event("close"));
|
|
226
|
-
expect(openCallback1).toHaveBeenCalledTimes(1);
|
|
227
|
-
expect(openCallback2).toHaveBeenCalledTimes(1);
|
|
228
|
-
expect(closeCallback1).toHaveBeenCalledTimes(1);
|
|
229
|
-
expect(messageCallback).toHaveBeenCalledTimes(1);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it("should handle add and remove operations interleaved", () => {
|
|
233
|
-
const subscriptions = new ConnectionSubscriptions();
|
|
234
|
-
const callback1 = vi.fn();
|
|
235
|
-
const callback2 = vi.fn();
|
|
236
|
-
const callback3 = vi.fn();
|
|
237
|
-
|
|
238
|
-
subscriptions.addSubscription("message", callback1);
|
|
239
|
-
subscriptions.addSubscription("message", callback2);
|
|
240
|
-
|
|
241
|
-
const msg1 = new MessageEvent("message", { data: "msg1" });
|
|
242
|
-
subscriptions.notify("message", msg1);
|
|
243
|
-
|
|
244
|
-
expect(callback1).toHaveBeenCalledWith(msg1);
|
|
245
|
-
expect(callback2).toHaveBeenCalledWith(msg1);
|
|
246
|
-
expect(callback3).not.toHaveBeenCalled();
|
|
247
|
-
|
|
248
|
-
subscriptions.removeSubscription("message", callback1);
|
|
249
|
-
subscriptions.addSubscription("message", callback3);
|
|
250
|
-
|
|
251
|
-
const msg2 = new MessageEvent("message", { data: "msg2" });
|
|
252
|
-
subscriptions.notify("message", msg2);
|
|
253
|
-
|
|
254
|
-
expect(callback1).toHaveBeenCalledTimes(1);
|
|
255
|
-
expect(callback2).toHaveBeenCalledWith(msg2);
|
|
256
|
-
expect(callback3).toHaveBeenCalledWith(msg2);
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it("should maintain separate subscription lists per event type", () => {
|
|
260
|
-
const subscriptions = new ConnectionSubscriptions();
|
|
261
|
-
const callback = vi.fn();
|
|
262
|
-
|
|
263
|
-
subscriptions.addSubscription("open", callback);
|
|
264
|
-
subscriptions.addSubscription("close", callback);
|
|
265
|
-
subscriptions.addSubscription("message", callback);
|
|
266
|
-
|
|
267
|
-
subscriptions.removeSubscription("close", callback);
|
|
268
|
-
|
|
269
|
-
subscriptions.notify("open", new Event("open"));
|
|
270
|
-
expect(callback).toHaveBeenCalledTimes(1);
|
|
271
|
-
|
|
272
|
-
subscriptions.notify(
|
|
273
|
-
"message",
|
|
274
|
-
new MessageEvent("message", { data: "test" }),
|
|
275
|
-
);
|
|
276
|
-
expect(callback).toHaveBeenCalledTimes(2);
|
|
277
|
-
|
|
278
|
-
subscriptions.notify("close", new Event("close"));
|
|
279
|
-
expect(callback).toHaveBeenCalledTimes(2);
|
|
280
|
-
});
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
describe("Edge cases", () => {
|
|
284
|
-
it("should handle callback that modifies subscriptions", () => {
|
|
285
|
-
const subscriptions = new ConnectionSubscriptions();
|
|
286
|
-
const callback1 = vi.fn();
|
|
287
|
-
const callback2 = vi.fn(() => {
|
|
288
|
-
subscriptions.removeSubscription("message", callback1);
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
subscriptions.addSubscription("message", callback1);
|
|
292
|
-
subscriptions.addSubscription("message", callback2);
|
|
293
|
-
|
|
294
|
-
const messageEvent = new MessageEvent("message", { data: "test" });
|
|
295
|
-
subscriptions.notify("message", messageEvent);
|
|
296
|
-
|
|
297
|
-
expect(callback1).toHaveBeenCalledTimes(1);
|
|
298
|
-
expect(callback2).toHaveBeenCalledTimes(1);
|
|
299
|
-
|
|
300
|
-
callback1.mockClear();
|
|
301
|
-
callback2.mockClear();
|
|
302
|
-
|
|
303
|
-
subscriptions.notify("message", messageEvent);
|
|
304
|
-
expect(callback1).not.toHaveBeenCalled();
|
|
305
|
-
expect(callback2).toHaveBeenCalledTimes(1);
|
|
306
|
-
});
|
|
307
|
-
});
|
|
308
|
-
});
|