@marimo-team/frontend 0.25.3-dev100 → 0.25.3-dev101
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/package.json +1 -1
- package/src/core/document/__tests__/lifecycle-scenarios.test.ts +61 -0
- package/src/core/document/__tests__/model.test.ts +0 -325
- package/src/core/document/__tests__/protocol-fixtures.ts +38 -0
- package/src/core/document/__tests__/recovery-scenarios.test.ts +230 -0
- package/src/core/document/__tests__/scenario-fixture.ts +42 -0
- package/src/core/document/__tests__/scenarios.test.ts +205 -0
- package/src/core/document/__tests__/submission.test.ts +22 -167
- package/src/core/document/__tests__/test-host.test.ts +92 -0
- package/src/core/document/__tests__/test-host.ts +247 -0
- package/src/core/document/__tests__/test-notebook.ts +139 -0
- package/src/core/document/__tests__/user.ts +61 -0
package/package.json
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { vi } from "vitest";
|
|
4
|
+
import { test } from "./scenario-fixture";
|
|
5
|
+
|
|
6
|
+
test("closing a session before the debounce prevents submission", async ({
|
|
7
|
+
expect,
|
|
8
|
+
session: { user, notebook, controller },
|
|
9
|
+
}) => {
|
|
10
|
+
user.changeCode("c3", "local");
|
|
11
|
+
const reason = new Error("Editing session ended");
|
|
12
|
+
controller.abort(reason);
|
|
13
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
14
|
+
expect(notebook.edit).not.toHaveBeenCalled();
|
|
15
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
16
|
+
await expect(user.sync()).rejects.toBe(reason);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("closing a session before a queued flush starts prevents submission", async ({
|
|
20
|
+
expect,
|
|
21
|
+
session: { user, notebook, controller },
|
|
22
|
+
}) => {
|
|
23
|
+
user.changeCode("c3", "local");
|
|
24
|
+
const flushed = user.sync();
|
|
25
|
+
const reason = new Error("Editing session ended");
|
|
26
|
+
controller.abort(reason);
|
|
27
|
+
await expect(flushed).rejects.toBe(reason);
|
|
28
|
+
expect(notebook.edit).not.toHaveBeenCalled();
|
|
29
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test.for(["request", "confirmation", "retry"] as const)(
|
|
33
|
+
"closing a session during %s stops synchronization",
|
|
34
|
+
async (phase, { expect, session: { user, host, notebook, controller } }) => {
|
|
35
|
+
user.changeCode("c3", "local");
|
|
36
|
+
const flushed = user.sync();
|
|
37
|
+
const request = await host.nextRequest();
|
|
38
|
+
if (phase === "confirmation") {
|
|
39
|
+
request.process();
|
|
40
|
+
request.deliverResponse();
|
|
41
|
+
} else if (phase === "retry") {
|
|
42
|
+
request.loseResponse();
|
|
43
|
+
}
|
|
44
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
45
|
+
const reason = new Error("Editing session ended");
|
|
46
|
+
controller.abort(reason);
|
|
47
|
+
await expect(flushed).rejects.toBe(reason);
|
|
48
|
+
if (phase === "request") {
|
|
49
|
+
expect(request.signal.aborted).toBe(true);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
host.changeCell("c1", { code: "later" });
|
|
53
|
+
host.deliverSnapshot();
|
|
54
|
+
user.changeCode("c3", "after closing");
|
|
55
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
56
|
+
expect(notebook.edit).toHaveBeenCalledOnce();
|
|
57
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
58
|
+
await expect(user.sync()).rejects.toBe(reason);
|
|
59
|
+
await expect(user.retry()).rejects.toBe(reason);
|
|
60
|
+
},
|
|
61
|
+
);
|
|
@@ -101,69 +101,6 @@ describe("document synchronization", () => {
|
|
|
101
101
|
expect(vi.getTimerCount()).toBe(0);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
it("releases unsent reversals and flushes an unchanged document without a request", async () => {
|
|
105
|
-
const { store, source, sent, edit, model, state, value } = setup();
|
|
106
|
-
edit("temporary");
|
|
107
|
-
edit("x = 1");
|
|
108
|
-
expect(state()).toEqual({ status: "idle" });
|
|
109
|
-
const remote = withCells(document(), { c1: cell("c1", "remote") });
|
|
110
|
-
store.set(source, remote);
|
|
111
|
-
expect(value()?.code).toBe("remote");
|
|
112
|
-
await expect(model.flush()).resolves.toEqual(remote.revision);
|
|
113
|
-
await vi.advanceTimersByTimeAsync(1000);
|
|
114
|
-
expect(sent).not.toHaveBeenCalled();
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it("flushes edits made after an earlier drain finishes but before its promise settles", async () => {
|
|
118
|
-
const { store, source, sent, replies, edit, model } = setup();
|
|
119
|
-
const first = model.flush();
|
|
120
|
-
await Promise.resolve();
|
|
121
|
-
edit("new code");
|
|
122
|
-
const second = model.flush();
|
|
123
|
-
const settled = vi.fn();
|
|
124
|
-
void second.then(settled, settled);
|
|
125
|
-
|
|
126
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
127
|
-
expect(sent).toHaveBeenCalledOnce();
|
|
128
|
-
expect(settled).not.toHaveBeenCalled();
|
|
129
|
-
expect(sent.mock.calls[0][0].changes).toMatchObject([
|
|
130
|
-
{ cell: { id: "c1", code: "new code" } },
|
|
131
|
-
]);
|
|
132
|
-
|
|
133
|
-
const accepted = withCells(document(), {
|
|
134
|
-
c1: cell("c1", "new code", { etag: "accepted", version: 2 }),
|
|
135
|
-
});
|
|
136
|
-
replies[0].resolve(response(accepted));
|
|
137
|
-
store.set(source, accepted);
|
|
138
|
-
await expect(second).resolves.toEqual(accepted.revision);
|
|
139
|
-
await expect(first).resolves.toEqual(document().revision);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("manually retries an uncertain request with its original identity", async () => {
|
|
143
|
-
const { store, source, sent, replies, edit, model } = setup();
|
|
144
|
-
edit("local");
|
|
145
|
-
const flushed = model.flush();
|
|
146
|
-
expect(model.flush()).toBe(flushed);
|
|
147
|
-
const rejected = expectFailure(flushed, "offline");
|
|
148
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
149
|
-
replies[0].reject(new Error("offline"));
|
|
150
|
-
await vi.advanceTimersByTimeAsync(500);
|
|
151
|
-
replies[1].reject(new Error("offline"));
|
|
152
|
-
await vi.advanceTimersByTimeAsync(1000);
|
|
153
|
-
replies[2].reject(new Error("offline"));
|
|
154
|
-
await rejected;
|
|
155
|
-
const retried = model.retry();
|
|
156
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
157
|
-
expect(sent.mock.calls[3][0]).toMatchObject({
|
|
158
|
-
idempotencyKey: sent.mock.calls[0][0].idempotencyKey,
|
|
159
|
-
changes: sent.mock.calls[0][0].changes,
|
|
160
|
-
});
|
|
161
|
-
const accepted = withCells(document(), { c1: cell("c1", "local") });
|
|
162
|
-
replies[3].resolve(response(accepted));
|
|
163
|
-
store.set(source, accepted);
|
|
164
|
-
await expect(retried).resolves.toEqual(accepted.revision);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
104
|
it("fails without repeating a request when the response names another notebook", async () => {
|
|
168
105
|
const { sent, replies, edit, model, state } = setup();
|
|
169
106
|
edit("local");
|
|
@@ -176,257 +113,6 @@ describe("document synchronization", () => {
|
|
|
176
113
|
expect(state()).toMatchObject({ status: "error" });
|
|
177
114
|
});
|
|
178
115
|
|
|
179
|
-
it("debounces typing and waits for streamed confirmation before sending newer edits", async () => {
|
|
180
|
-
const { store, source, sent, replies, edit, model, state, value } = setup();
|
|
181
|
-
edit("first");
|
|
182
|
-
await vi.advanceTimersByTimeAsync(300);
|
|
183
|
-
edit("sent");
|
|
184
|
-
await vi.advanceTimersByTimeAsync(399);
|
|
185
|
-
expect(sent).not.toHaveBeenCalled();
|
|
186
|
-
await vi.advanceTimersByTimeAsync(1);
|
|
187
|
-
expect(sent).toHaveBeenCalledOnce();
|
|
188
|
-
expect(sent.mock.calls[0][0].changes[0]).toMatchObject({
|
|
189
|
-
cell: { code: "sent" },
|
|
190
|
-
});
|
|
191
|
-
edit("newer");
|
|
192
|
-
const accepted = withCells(document(), {
|
|
193
|
-
c1: cell("c1", "sent", { etag: "accepted" }),
|
|
194
|
-
});
|
|
195
|
-
replies[0].resolve(response(accepted));
|
|
196
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
197
|
-
expect(state()).toEqual({ status: "confirming" });
|
|
198
|
-
await vi.advanceTimersByTimeAsync(1000);
|
|
199
|
-
expect(sent).toHaveBeenCalledOnce();
|
|
200
|
-
store.set(source, accepted);
|
|
201
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
202
|
-
expect(sent).toHaveBeenCalledTimes(2);
|
|
203
|
-
expect(sent.mock.calls[1][0].changes[0]).toMatchObject({
|
|
204
|
-
base_etag: "accepted",
|
|
205
|
-
cell: { code: "newer" },
|
|
206
|
-
});
|
|
207
|
-
const latest = withCells(accepted, { c1: cell("c1", "newer") });
|
|
208
|
-
const flushed = model.flush();
|
|
209
|
-
replies[1].resolve(response(latest));
|
|
210
|
-
store.set(source, latest);
|
|
211
|
-
await expect(flushed).resolves.toEqual(latest.revision);
|
|
212
|
-
expect(state()).toEqual({ status: "idle" });
|
|
213
|
-
expect(value()?.code).toBe("newer");
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it.each(["response", "snapshot"] as const)(
|
|
217
|
-
"confirms a reset when %s arrives first without opening a stream",
|
|
218
|
-
async (first) => {
|
|
219
|
-
const { store, source, sent, replies, edit, model } = setup();
|
|
220
|
-
edit("sent");
|
|
221
|
-
const flushed = model.flush();
|
|
222
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
223
|
-
store.set(source, null);
|
|
224
|
-
const accepted = withCells(document(), { c1: cell("c1", "sent") });
|
|
225
|
-
const later = withCells(accepted, { c2: cell("c2", "remote") });
|
|
226
|
-
if (first === "response") {
|
|
227
|
-
replies[0].resolve(response(accepted));
|
|
228
|
-
} else {
|
|
229
|
-
store.set(source, later);
|
|
230
|
-
}
|
|
231
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
232
|
-
expect(store.get(model.syncStateAtom)).toEqual({
|
|
233
|
-
status: first === "response" ? "confirming" : "sending",
|
|
234
|
-
});
|
|
235
|
-
if (first === "response") {
|
|
236
|
-
store.set(source, later);
|
|
237
|
-
} else {
|
|
238
|
-
replies[0].resolve(response(accepted));
|
|
239
|
-
}
|
|
240
|
-
await expect(flushed).resolves.toEqual(later.revision);
|
|
241
|
-
expect(sent).toHaveBeenCalledOnce();
|
|
242
|
-
expect(store.get(model.syncStateAtom)).toEqual({ status: "idle" });
|
|
243
|
-
},
|
|
244
|
-
);
|
|
245
|
-
|
|
246
|
-
it("retries a lost response with the captured body and key, preserving subsequent typing", async () => {
|
|
247
|
-
const { store, source, sent, replies, edit, model, value } = setup();
|
|
248
|
-
edit("sent");
|
|
249
|
-
const flushed = model.flush();
|
|
250
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
251
|
-
const accepted = withCells(document(), {
|
|
252
|
-
c1: cell("c1", "sent", { etag: "accepted" }),
|
|
253
|
-
});
|
|
254
|
-
store.set(source, accepted);
|
|
255
|
-
edit("newer");
|
|
256
|
-
replies[0].reject(new Error("response lost"));
|
|
257
|
-
await vi.advanceTimersByTimeAsync(500);
|
|
258
|
-
expect(sent).toHaveBeenCalledTimes(2);
|
|
259
|
-
const original = sent.mock.calls[0][0];
|
|
260
|
-
const retry = sent.mock.calls[1][0];
|
|
261
|
-
expect(retry.idempotencyKey).toBe(original.idempotencyKey);
|
|
262
|
-
expect(retry.changes).toBe(original.changes);
|
|
263
|
-
replies[1].resolve(response(accepted));
|
|
264
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
265
|
-
expect(sent).toHaveBeenCalledTimes(3);
|
|
266
|
-
expect(sent.mock.calls[2][0].idempotencyKey).not.toBe(
|
|
267
|
-
original.idempotencyKey,
|
|
268
|
-
);
|
|
269
|
-
const latest = withCells(accepted, { c1: cell("c1", "newer") });
|
|
270
|
-
store.set(source, latest);
|
|
271
|
-
replies[2].resolve(response(latest));
|
|
272
|
-
await expect(flushed).resolves.toEqual(latest.revision);
|
|
273
|
-
expect(value()?.code).toBe("newer");
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
it("exposes conflicts and only retries explicitly against a fresh confirmed revision", async () => {
|
|
277
|
-
const { store, source, sent, replies, edit, model, state, value } = setup();
|
|
278
|
-
edit("local");
|
|
279
|
-
const flushed = model.flush();
|
|
280
|
-
const rejected = expectFailure(flushed, "conflict");
|
|
281
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
282
|
-
const remote = withCells(document(), {
|
|
283
|
-
c1: cell("c1", "remote", { etag: "remote" }),
|
|
284
|
-
});
|
|
285
|
-
const problem: Protocol.Problem = {
|
|
286
|
-
type: "tag:marimo.io,2026:host/conflict",
|
|
287
|
-
title: "conflict",
|
|
288
|
-
status: 409,
|
|
289
|
-
edit: {
|
|
290
|
-
change: 0,
|
|
291
|
-
cell: remote.cells.get("c1")!,
|
|
292
|
-
document: remote.revision,
|
|
293
|
-
},
|
|
294
|
-
};
|
|
295
|
-
replies[0].resolve({ ok: false, status: 409, problem });
|
|
296
|
-
await rejected;
|
|
297
|
-
expect(state()).toMatchObject({
|
|
298
|
-
status: "error",
|
|
299
|
-
problem,
|
|
300
|
-
retryable: true,
|
|
301
|
-
});
|
|
302
|
-
await expect(model.retry()).rejects.toThrow("Wait for the host");
|
|
303
|
-
edit("updated local");
|
|
304
|
-
store.set(source, remote);
|
|
305
|
-
await vi.advanceTimersByTimeAsync(2000);
|
|
306
|
-
expect(sent).toHaveBeenCalledOnce();
|
|
307
|
-
expect(value()?.code).toBe("updated local");
|
|
308
|
-
const retry = model.retry();
|
|
309
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
310
|
-
expect(sent.mock.calls[1][0].changes[0]).toMatchObject({
|
|
311
|
-
base_etag: "remote",
|
|
312
|
-
cell: { code: "updated local" },
|
|
313
|
-
});
|
|
314
|
-
const accepted = withCells(remote, { c1: cell("c1", "updated local") });
|
|
315
|
-
replies[1].resolve(response(accepted));
|
|
316
|
-
store.set(source, accepted);
|
|
317
|
-
await expect(retry).resolves.toEqual(accepted.revision);
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
it("retains a draft after remote deletion and never sends the other edits alone", async () => {
|
|
321
|
-
const { store, source, model, sent, replies, edit } = setup();
|
|
322
|
-
edit("keep this");
|
|
323
|
-
const flushed = model.flush();
|
|
324
|
-
const rejected = expectFailure(flushed, "removed");
|
|
325
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
326
|
-
const removed = withCells(document(), { c1: null });
|
|
327
|
-
store.set(source, removed);
|
|
328
|
-
replies[0].resolve({
|
|
329
|
-
ok: false,
|
|
330
|
-
status: 409,
|
|
331
|
-
problem: {
|
|
332
|
-
type: "tag:marimo.io,2026:host/conflict",
|
|
333
|
-
title: "removed",
|
|
334
|
-
status: 409,
|
|
335
|
-
edit: { change: 0, cell: null, document: removed.revision },
|
|
336
|
-
},
|
|
337
|
-
});
|
|
338
|
-
await rejected;
|
|
339
|
-
store.set(model.editAtom, (tx) => tx.setCode("c2", "other edit"));
|
|
340
|
-
await expect(model.retry()).rejects.toThrow("unavailable");
|
|
341
|
-
expect(sent).toHaveBeenCalledOnce();
|
|
342
|
-
store.set(source, withCells(removed, { c1: cell("c1", "restored") }));
|
|
343
|
-
expect(store.get(model.cellAtom("c1"))?.code).toBe("keep this");
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
it("bounds retries and never replays an uncertain write after receipt expiry", async () => {
|
|
347
|
-
const { sent, replies, edit, model, state } = setup();
|
|
348
|
-
edit("local");
|
|
349
|
-
const flushed = model.flush();
|
|
350
|
-
const rejected = expectFailure(flushed, "offline");
|
|
351
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
352
|
-
replies[0].reject(new Error("offline"));
|
|
353
|
-
await vi.advanceTimersByTimeAsync(500);
|
|
354
|
-
replies[1].reject(new Error("offline"));
|
|
355
|
-
await vi.advanceTimersByTimeAsync(1000);
|
|
356
|
-
replies[2].reject(new Error("offline"));
|
|
357
|
-
await rejected;
|
|
358
|
-
expect(state()).toMatchObject({ status: "error", retryable: true });
|
|
359
|
-
await vi.advanceTimersByTimeAsync(60_000);
|
|
360
|
-
await expect(model.retry()).rejects.toThrow("retry window expired");
|
|
361
|
-
expect(sent).toHaveBeenCalledTimes(3);
|
|
362
|
-
expect(state()).toMatchObject({ status: "error", retryable: false });
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
it("times out a hung attempt, aborts it, and retries the same request", async () => {
|
|
366
|
-
const { store, source, sent, replies, edit, model } = setup();
|
|
367
|
-
edit("local");
|
|
368
|
-
const flushed = model.flush();
|
|
369
|
-
await vi.advanceTimersByTimeAsync(10_500);
|
|
370
|
-
expect(sent).toHaveBeenCalledTimes(2);
|
|
371
|
-
expect(sent.mock.calls[0][0].signal.aborted).toBe(true);
|
|
372
|
-
expect(sent.mock.calls[1][0].idempotencyKey).toBe(
|
|
373
|
-
sent.mock.calls[0][0].idempotencyKey,
|
|
374
|
-
);
|
|
375
|
-
const accepted = withCells(document(), { c1: cell("c1", "local") });
|
|
376
|
-
replies[1].resolve(response(accepted));
|
|
377
|
-
store.set(source, accepted);
|
|
378
|
-
await expect(flushed).resolves.toEqual(accepted.revision);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
it("aborts before the debounce without sending any edits", async () => {
|
|
382
|
-
const { sent, edit, model, controller } = setup();
|
|
383
|
-
edit("local");
|
|
384
|
-
controller.abort();
|
|
385
|
-
await vi.advanceTimersByTimeAsync(1000);
|
|
386
|
-
expect(sent).not.toHaveBeenCalled();
|
|
387
|
-
expect(vi.getTimerCount()).toBe(0);
|
|
388
|
-
await expect(model.flush()).rejects.toThrow("aborted");
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
it.each(["request", "confirmation", "retry"] as const)(
|
|
392
|
-
"aborts during %s without leaving work or subscriptions",
|
|
393
|
-
async (phase) => {
|
|
394
|
-
const {
|
|
395
|
-
store,
|
|
396
|
-
source,
|
|
397
|
-
sent,
|
|
398
|
-
replies,
|
|
399
|
-
edit,
|
|
400
|
-
model,
|
|
401
|
-
controller,
|
|
402
|
-
unmounted,
|
|
403
|
-
} = setup();
|
|
404
|
-
edit("local");
|
|
405
|
-
const flushed = model.flush();
|
|
406
|
-
const reason = new Error("Editing session ended");
|
|
407
|
-
const rejected = expectAbort(flushed, reason);
|
|
408
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
409
|
-
if (phase === "confirmation") {
|
|
410
|
-
replies[0].resolve(
|
|
411
|
-
response(withCells(document(), { c1: cell("c1", "local") })),
|
|
412
|
-
);
|
|
413
|
-
}
|
|
414
|
-
if (phase === "retry") {
|
|
415
|
-
replies[0].reject(new Error("offline"));
|
|
416
|
-
}
|
|
417
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
418
|
-
controller.abort(reason);
|
|
419
|
-
await rejected;
|
|
420
|
-
expect(unmounted).toHaveBeenCalledOnce();
|
|
421
|
-
expect(sent).toHaveBeenCalledOnce();
|
|
422
|
-
store.set(source, withCells(document(), { c1: cell("c1", "later") }));
|
|
423
|
-
edit("after disposal");
|
|
424
|
-
expect(vi.getTimerCount()).toBe(0);
|
|
425
|
-
await expect(model.flush()).rejects.toBe(reason);
|
|
426
|
-
await expect(model.retry()).rejects.toBe(reason);
|
|
427
|
-
},
|
|
428
|
-
);
|
|
429
|
-
|
|
430
116
|
it("ignores a late response and source update after its lifetime ends", async () => {
|
|
431
117
|
const {
|
|
432
118
|
store,
|
|
@@ -458,17 +144,6 @@ describe("document synchronization", () => {
|
|
|
458
144
|
expect(vi.getTimerCount()).toBe(0);
|
|
459
145
|
});
|
|
460
146
|
|
|
461
|
-
it("rejects a queued flush when aborted before submission", async () => {
|
|
462
|
-
const { model, edit, sent, controller } = setup();
|
|
463
|
-
edit("local");
|
|
464
|
-
const reason = new Error("Editing session ended");
|
|
465
|
-
const rejected = expectAbort(model.flush(), reason);
|
|
466
|
-
controller.abort(reason);
|
|
467
|
-
await rejected;
|
|
468
|
-
expect(sent).not.toHaveBeenCalled();
|
|
469
|
-
expect(vi.getTimerCount()).toBe(0);
|
|
470
|
-
});
|
|
471
|
-
|
|
472
147
|
it("aborts an in-flight edit when notebook identity changes", async () => {
|
|
473
148
|
const { store, source, sent, edit, model, state } = setup();
|
|
474
149
|
edit("local");
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import NodeUrl from "node:url";
|
|
5
|
+
import type * as Protocol from "@marimo-team/client/protocol";
|
|
6
|
+
import { invariant } from "@/utils/invariant";
|
|
7
|
+
import { fixtureEvents } from "../../host/__tests__/fixtures";
|
|
8
|
+
import type { Document } from "../../host/resources/document";
|
|
9
|
+
|
|
10
|
+
export function protocolFixture(name: string): unknown {
|
|
11
|
+
return JSON.parse(
|
|
12
|
+
readFileSync(
|
|
13
|
+
new NodeUrl.URL(
|
|
14
|
+
`../../../../../protocols/host/fixtures/${name}`,
|
|
15
|
+
import.meta.url,
|
|
16
|
+
),
|
|
17
|
+
"utf8",
|
|
18
|
+
),
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function initialDocument(): Document {
|
|
23
|
+
const cells = new Map<Protocol.CellId, Protocol.Cell>();
|
|
24
|
+
let revision: Protocol.Document | undefined;
|
|
25
|
+
for (const event of fixtureEvents("notebook-events.sse")) {
|
|
26
|
+
if (event.event === "ready") {
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
if (event.event === "cell") {
|
|
30
|
+
cells.set(event.data.cell.id, event.data.cell);
|
|
31
|
+
}
|
|
32
|
+
if (event.event === "document") {
|
|
33
|
+
revision = event.data.document;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
invariant(revision !== undefined, "Fixture has no document");
|
|
37
|
+
return { revision, cells };
|
|
38
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { vi } from "vitest";
|
|
4
|
+
import { test } from "./scenario-fixture";
|
|
5
|
+
|
|
6
|
+
test.for(["response", "document"] as const)(
|
|
7
|
+
"keeps a conflicting batch intact and retries explicitly with %s first",
|
|
8
|
+
async (first, { expect, session: { user, host, notebook } }) => {
|
|
9
|
+
// The first change is valid; the second conflicts. Neither may commit.
|
|
10
|
+
user.changeCells({
|
|
11
|
+
c1: { code: "other local edit" },
|
|
12
|
+
c3: { code: "local" },
|
|
13
|
+
});
|
|
14
|
+
const synced = user.sync();
|
|
15
|
+
const request = await host.nextRequest();
|
|
16
|
+
host.changeCell("c3", { code: "remote", name: "remote name" });
|
|
17
|
+
const remote = host.document();
|
|
18
|
+
expect(user.cell("c3")?.code).toBe("local");
|
|
19
|
+
request.process();
|
|
20
|
+
expect(host.document()).toEqual(remote);
|
|
21
|
+
user.changeCode("c3", "updated local");
|
|
22
|
+
|
|
23
|
+
if (first === "document") {
|
|
24
|
+
host.deliverDocument();
|
|
25
|
+
}
|
|
26
|
+
request.deliverResponse();
|
|
27
|
+
await expect(synced).rejects.toThrow("conflict");
|
|
28
|
+
expect(user.status()).toMatchObject({ status: "error", retryable: true });
|
|
29
|
+
if (first === "response") {
|
|
30
|
+
host.deliverDocument();
|
|
31
|
+
}
|
|
32
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
33
|
+
expect(notebook.edit).toHaveBeenCalledOnce();
|
|
34
|
+
expect(user.cell("c3")?.code).toBe("updated local");
|
|
35
|
+
expect(user.cell("c1")?.code).toBe("other local edit");
|
|
36
|
+
|
|
37
|
+
const retried = user.retry();
|
|
38
|
+
const retry = await host.nextRequest();
|
|
39
|
+
expect(retry.idempotencyKey).not.toBe(request.idempotencyKey);
|
|
40
|
+
retry.process();
|
|
41
|
+
retry.deliverResponse();
|
|
42
|
+
host.deliverDocument();
|
|
43
|
+
await expect(retried).resolves.toEqual(host.document().revision);
|
|
44
|
+
expect(host.document().cells.get("c1")?.code).toBe("other local edit");
|
|
45
|
+
expect(host.document().cells.get("c3")).toMatchObject({
|
|
46
|
+
code: "updated local",
|
|
47
|
+
name: "remote name",
|
|
48
|
+
});
|
|
49
|
+
expect(user.cell("c3")).toMatchObject({
|
|
50
|
+
code: "updated local",
|
|
51
|
+
name: "remote name",
|
|
52
|
+
});
|
|
53
|
+
expect(user.status()).toEqual({ status: "idle" });
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
test("waits for the conflicting revision before allowing a retry", async ({
|
|
58
|
+
expect,
|
|
59
|
+
session: { user, host, notebook },
|
|
60
|
+
}) => {
|
|
61
|
+
user.changeCode("c3", "local");
|
|
62
|
+
const synced = user.sync();
|
|
63
|
+
const request = await host.nextRequest();
|
|
64
|
+
host.changeCell("c3", { code: "remote" });
|
|
65
|
+
request.process();
|
|
66
|
+
request.deliverResponse();
|
|
67
|
+
await expect(synced).rejects.toThrow("conflict");
|
|
68
|
+
await expect(user.retry()).rejects.toThrow("Wait for the host");
|
|
69
|
+
expect(notebook.edit).toHaveBeenCalledOnce();
|
|
70
|
+
expect(user.cell("c3")?.code).toBe("local");
|
|
71
|
+
|
|
72
|
+
host.deliverDocument();
|
|
73
|
+
const retried = user.retry();
|
|
74
|
+
const retry = await host.nextRequest();
|
|
75
|
+
retry.process();
|
|
76
|
+
retry.deliverResponse();
|
|
77
|
+
host.deliverDocument();
|
|
78
|
+
await expect(retried).resolves.toEqual(host.document().revision);
|
|
79
|
+
expect(host.document().cells.get("c3")?.code).toBe("local");
|
|
80
|
+
expect(user.status()).toEqual({ status: "idle" });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("replays a lost response without committing twice or losing newer typing", async ({
|
|
84
|
+
expect,
|
|
85
|
+
session: { user, host },
|
|
86
|
+
}) => {
|
|
87
|
+
user.changeCode("c3", "sent");
|
|
88
|
+
const flushed = user.sync();
|
|
89
|
+
const request = await host.nextRequest();
|
|
90
|
+
request.process();
|
|
91
|
+
const committed = host.document();
|
|
92
|
+
host.deliverDocument();
|
|
93
|
+
user.changeCode("c3", "newer");
|
|
94
|
+
request.loseResponse();
|
|
95
|
+
|
|
96
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
97
|
+
const retry = await host.nextRequest();
|
|
98
|
+
expect(retry.idempotencyKey).toBe(request.idempotencyKey);
|
|
99
|
+
expect(retry.changes).toEqual(request.changes);
|
|
100
|
+
retry.process();
|
|
101
|
+
expect(host.document()).toEqual(committed);
|
|
102
|
+
retry.deliverResponse();
|
|
103
|
+
|
|
104
|
+
const next = await host.nextRequest();
|
|
105
|
+
expect(next.idempotencyKey).not.toBe(request.idempotencyKey);
|
|
106
|
+
next.process();
|
|
107
|
+
next.deliverResponse();
|
|
108
|
+
host.deliverDocument();
|
|
109
|
+
await expect(flushed).resolves.toEqual(host.document().revision);
|
|
110
|
+
expect(host.document().revision.version).toBe(committed.revision.version + 1);
|
|
111
|
+
expect(host.document().cells.get("c3")?.code).toBe("newer");
|
|
112
|
+
expect(user.cell("c3")?.code).toBe("newer");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("ignores a timed-out attempt that completes after its retry", async ({
|
|
116
|
+
expect,
|
|
117
|
+
session: { user, host, notebook },
|
|
118
|
+
}) => {
|
|
119
|
+
user.changeCode("c3", "local");
|
|
120
|
+
const flushed = user.sync();
|
|
121
|
+
const first = await host.nextRequest();
|
|
122
|
+
await vi.advanceTimersByTimeAsync(10_500);
|
|
123
|
+
const retry = await host.nextRequest();
|
|
124
|
+
expect(first.signal.aborted).toBe(true);
|
|
125
|
+
expect(retry.idempotencyKey).toBe(first.idempotencyKey);
|
|
126
|
+
retry.process();
|
|
127
|
+
const committed = host.document();
|
|
128
|
+
retry.deliverResponse();
|
|
129
|
+
host.deliverDocument();
|
|
130
|
+
await expect(flushed).resolves.toEqual(committed.revision);
|
|
131
|
+
first.process();
|
|
132
|
+
first.deliverResponse();
|
|
133
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
134
|
+
expect(host.document()).toEqual(committed);
|
|
135
|
+
expect(user.cell("c3")?.code).toBe("local");
|
|
136
|
+
expect(notebook.edit).toHaveBeenCalledTimes(2);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a manual retry reuses the committed edit after automatic retries give up", async ({
|
|
140
|
+
expect,
|
|
141
|
+
session: { user, host, notebook },
|
|
142
|
+
}) => {
|
|
143
|
+
user.changeCode("c3", "sent");
|
|
144
|
+
const synced = user.sync();
|
|
145
|
+
const request = await host.nextRequest();
|
|
146
|
+
request.process();
|
|
147
|
+
const committed = host.document();
|
|
148
|
+
request.loseResponse();
|
|
149
|
+
for (const delay of [500, 1000]) {
|
|
150
|
+
await vi.advanceTimersByTimeAsync(delay);
|
|
151
|
+
const retry = await host.nextRequest();
|
|
152
|
+
retry.process();
|
|
153
|
+
retry.loseResponse();
|
|
154
|
+
}
|
|
155
|
+
await expect(synced).rejects.toThrow("Connection lost");
|
|
156
|
+
expect(user.status()).toMatchObject({ status: "error", retryable: true });
|
|
157
|
+
expect(notebook.edit).toHaveBeenCalledTimes(3);
|
|
158
|
+
expect(host.document()).toEqual(committed);
|
|
159
|
+
|
|
160
|
+
const retried = user.retry();
|
|
161
|
+
const retry = await host.nextRequest();
|
|
162
|
+
expect(retry.idempotencyKey).toBe(request.idempotencyKey);
|
|
163
|
+
expect(retry.changes).toEqual(request.changes);
|
|
164
|
+
retry.process();
|
|
165
|
+
retry.deliverResponse();
|
|
166
|
+
host.deliverDocument();
|
|
167
|
+
await expect(retried).resolves.toEqual(committed.revision);
|
|
168
|
+
expect(host.document()).toEqual(committed);
|
|
169
|
+
expect(user.status()).toEqual({ status: "idle" });
|
|
170
|
+
expect(notebook.edit).toHaveBeenCalledTimes(4);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("refuses a manual retry after the safe retry window while retaining the draft", async ({
|
|
174
|
+
expect,
|
|
175
|
+
session: { user, host, notebook },
|
|
176
|
+
}) => {
|
|
177
|
+
user.changeCode("c3", "sent");
|
|
178
|
+
const synced = user.sync();
|
|
179
|
+
const request = await host.nextRequest();
|
|
180
|
+
request.process();
|
|
181
|
+
const committed = host.document();
|
|
182
|
+
request.loseResponse();
|
|
183
|
+
for (const delay of [500, 1000]) {
|
|
184
|
+
await vi.advanceTimersByTimeAsync(delay);
|
|
185
|
+
const retry = await host.nextRequest();
|
|
186
|
+
retry.process();
|
|
187
|
+
retry.loseResponse();
|
|
188
|
+
}
|
|
189
|
+
await expect(synced).rejects.toThrow("Connection lost");
|
|
190
|
+
expect(user.status()).toMatchObject({ status: "error", retryable: true });
|
|
191
|
+
|
|
192
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
193
|
+
await expect(user.retry()).rejects.toThrow("retry window expired");
|
|
194
|
+
expect(user.status()).toMatchObject({ status: "error", retryable: false });
|
|
195
|
+
expect(notebook.edit).toHaveBeenCalledTimes(3);
|
|
196
|
+
expect(host.document()).toEqual(committed);
|
|
197
|
+
expect(user.cell("c3")?.code).toBe("sent");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("keeps the whole draft when another writer deletes an edited cell", async ({
|
|
201
|
+
expect,
|
|
202
|
+
session: { user, host, notebook },
|
|
203
|
+
}) => {
|
|
204
|
+
user.changeCode("c3", "keep this");
|
|
205
|
+
const synced = user.sync();
|
|
206
|
+
const request = await host.nextRequest();
|
|
207
|
+
host.deleteCell("c3");
|
|
208
|
+
request.process();
|
|
209
|
+
request.deliverResponse();
|
|
210
|
+
host.deliverDocument();
|
|
211
|
+
await expect(synced).rejects.toThrow("conflict");
|
|
212
|
+
|
|
213
|
+
user.changeCode("c1", "other edit");
|
|
214
|
+
await expect(user.retry()).rejects.toThrow("unavailable");
|
|
215
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
216
|
+
expect(notebook.edit).toHaveBeenCalledOnce();
|
|
217
|
+
expect(host.document().cells.get("c1")?.code).toBe("import marimo as mo");
|
|
218
|
+
|
|
219
|
+
host.createCell({ id: "c3", code: "restored" });
|
|
220
|
+
host.deliverDocument();
|
|
221
|
+
expect(user.cell("c3")?.code).toBe("keep this");
|
|
222
|
+
const retried = user.retry();
|
|
223
|
+
const retry = await host.nextRequest();
|
|
224
|
+
retry.process();
|
|
225
|
+
retry.deliverResponse();
|
|
226
|
+
host.deliverDocument();
|
|
227
|
+
await expect(retried).resolves.toEqual(host.document().revision);
|
|
228
|
+
expect(host.document().cells.get("c3")?.code).toBe("keep this");
|
|
229
|
+
expect(host.document().cells.get("c1")?.code).toBe("other edit");
|
|
230
|
+
});
|