@marimo-team/frontend 0.25.3-dev100 → 0.25.3-dev102
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 +6 -331
- 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__/state.test.ts +5 -13
- package/src/core/document/__tests__/submission.test.ts +24 -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/src/core/document/index.ts +3 -1
- package/src/core/document/model.ts +7 -8
- package/src/core/document/select.ts +32 -0
- package/src/core/document/state.ts +3 -30
- package/src/core/document/sync.ts +29 -25
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { createStore } from "jotai/vanilla";
|
|
4
|
+
import { test as baseTest, vi } from "vitest";
|
|
5
|
+
import type { Document } from "../../host/resources/document";
|
|
6
|
+
import { createDocumentModel } from "../model";
|
|
7
|
+
import { initialDocument } from "./protocol-fixtures";
|
|
8
|
+
import { createTestNotebook } from "./test-notebook";
|
|
9
|
+
import { createUser } from "./user";
|
|
10
|
+
|
|
11
|
+
function createSession(initial: Document, controller: AbortController) {
|
|
12
|
+
const store = createStore();
|
|
13
|
+
const { notebook, control } = createTestNotebook(store, initial);
|
|
14
|
+
const model = createDocumentModel({
|
|
15
|
+
store,
|
|
16
|
+
notebook,
|
|
17
|
+
signal: controller.signal,
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
user: createUser(store, model),
|
|
21
|
+
host: control,
|
|
22
|
+
notebook,
|
|
23
|
+
controller,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const test = baseTest.extend<{
|
|
28
|
+
initial: Document;
|
|
29
|
+
session: ReturnType<typeof createSession>;
|
|
30
|
+
}>({
|
|
31
|
+
initial: initialDocument(),
|
|
32
|
+
session: async ({ initial }, provide) => {
|
|
33
|
+
vi.useFakeTimers({ now: 0 });
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
try {
|
|
36
|
+
await provide(createSession(initial, controller));
|
|
37
|
+
} finally {
|
|
38
|
+
controller.abort();
|
|
39
|
+
vi.useRealTimers();
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { vi } from "vitest";
|
|
4
|
+
import { test } from "./scenario-fixture";
|
|
5
|
+
|
|
6
|
+
test("waits for a pause in typing before sending the latest edit", async ({
|
|
7
|
+
expect,
|
|
8
|
+
session: { user, host, notebook },
|
|
9
|
+
}) => {
|
|
10
|
+
user.changeCode("c3", "first");
|
|
11
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
12
|
+
user.changeCode("c3", "latest");
|
|
13
|
+
await vi.advanceTimersByTimeAsync(399);
|
|
14
|
+
expect(notebook.edit).not.toHaveBeenCalled();
|
|
15
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
16
|
+
expect(notebook.edit).toHaveBeenCalledOnce();
|
|
17
|
+
const request = await host.nextRequest();
|
|
18
|
+
request.process();
|
|
19
|
+
request.deliverResponse();
|
|
20
|
+
host.deliverDocument();
|
|
21
|
+
await expect(user.sync()).resolves.toEqual(host.document().revision);
|
|
22
|
+
expect(host.document().cells.get("c3")?.code).toBe("latest");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test.for(["response", "document"] as const)(
|
|
26
|
+
"preserves newer typing when the %s arrives first",
|
|
27
|
+
async (first, { expect, session: { user, host, notebook } }) => {
|
|
28
|
+
user.changeCells({ c3: { code: "sent", config: { hide_code: true } } });
|
|
29
|
+
const flushed = user.sync();
|
|
30
|
+
const finished = vi.fn();
|
|
31
|
+
void flushed.then(finished, () => undefined);
|
|
32
|
+
const request = await host.nextRequest();
|
|
33
|
+
request.process();
|
|
34
|
+
user.changeCode("c3", "still typing");
|
|
35
|
+
|
|
36
|
+
const deliver = {
|
|
37
|
+
response: () => request.deliverResponse(),
|
|
38
|
+
document: () => host.deliverDocument(),
|
|
39
|
+
};
|
|
40
|
+
deliver[first]();
|
|
41
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
42
|
+
expect(finished).not.toHaveBeenCalled();
|
|
43
|
+
expect(notebook.edit).toHaveBeenCalledOnce();
|
|
44
|
+
expect(user.cell("c3")?.code).toBe("still typing");
|
|
45
|
+
|
|
46
|
+
deliver[first === "response" ? "document" : "response"]();
|
|
47
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
48
|
+
expect(user.cell("c3")?.code).toBe("still typing");
|
|
49
|
+
const next = await host.nextRequest();
|
|
50
|
+
next.process();
|
|
51
|
+
next.deliverResponse();
|
|
52
|
+
host.deliverDocument();
|
|
53
|
+
await expect(flushed).resolves.toEqual(host.document().revision);
|
|
54
|
+
const expected = {
|
|
55
|
+
id: "c3",
|
|
56
|
+
code: "still typing",
|
|
57
|
+
name: "_",
|
|
58
|
+
config: {
|
|
59
|
+
column: null,
|
|
60
|
+
disabled: false,
|
|
61
|
+
hide_code: true,
|
|
62
|
+
expand_output: false,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
expect(user.cell("c3")).toEqual(expected);
|
|
66
|
+
expect(host.document().cells.get("c3")).toMatchObject(expected);
|
|
67
|
+
expect(user.status()).toEqual({ status: "idle" });
|
|
68
|
+
|
|
69
|
+
host.changeCell("c3", {
|
|
70
|
+
code: "another writer",
|
|
71
|
+
config: { hide_code: false },
|
|
72
|
+
});
|
|
73
|
+
host.deliverDocument();
|
|
74
|
+
expect(user.cell("c3")?.code).toBe("another writer");
|
|
75
|
+
expect(user.cell("c3")?.config.hide_code).toBe(false);
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
test("uses a newer confirmed document for edits that have not been sent", async ({
|
|
80
|
+
expect,
|
|
81
|
+
session: { user, host },
|
|
82
|
+
}) => {
|
|
83
|
+
user.changeCode("c3", "local");
|
|
84
|
+
host.changeCell("c3", { name: "remote name" });
|
|
85
|
+
host.deliverDocument();
|
|
86
|
+
const flushed = user.sync();
|
|
87
|
+
const request = await host.nextRequest();
|
|
88
|
+
request.process();
|
|
89
|
+
request.deliverResponse();
|
|
90
|
+
host.deliverDocument();
|
|
91
|
+
await expect(flushed).resolves.toEqual(host.document().revision);
|
|
92
|
+
expect(host.document().cells.get("c3")).toMatchObject({
|
|
93
|
+
code: "local",
|
|
94
|
+
name: "remote name",
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test.for(["before reset", "during reset", "after snapshot"] as const)(
|
|
99
|
+
"recovers across reset when the response arrives %s",
|
|
100
|
+
async (arrival, { expect, session: { user, host } }) => {
|
|
101
|
+
user.changeCode("c3", "sent");
|
|
102
|
+
const flushed = user.sync();
|
|
103
|
+
const request = await host.nextRequest();
|
|
104
|
+
request.process();
|
|
105
|
+
user.changeCode("c3", "newer typing");
|
|
106
|
+
if (arrival === "before reset") {
|
|
107
|
+
request.deliverResponse();
|
|
108
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
109
|
+
}
|
|
110
|
+
host.reset();
|
|
111
|
+
if (arrival === "during reset") {
|
|
112
|
+
request.deliverResponse();
|
|
113
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
114
|
+
}
|
|
115
|
+
host.changeCell("c1", { code: "unrelated edit" });
|
|
116
|
+
host.deliverSnapshot();
|
|
117
|
+
if (arrival === "after snapshot") {
|
|
118
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
119
|
+
request.deliverResponse();
|
|
120
|
+
}
|
|
121
|
+
expect(user.cell("c3")?.code).toBe("newer typing");
|
|
122
|
+
const next = await host.nextRequest();
|
|
123
|
+
next.process();
|
|
124
|
+
next.deliverResponse();
|
|
125
|
+
host.deliverDocument();
|
|
126
|
+
await expect(flushed).resolves.toEqual(host.document().revision);
|
|
127
|
+
expect(host.document().cells.get("c3")?.code).toBe("newer typing");
|
|
128
|
+
expect(user.cell("c1")?.code).toBe("unrelated edit");
|
|
129
|
+
expect(user.status()).toEqual({ status: "idle" });
|
|
130
|
+
|
|
131
|
+
host.changeCell("c3", { code: "another writer" });
|
|
132
|
+
host.deliverDocument();
|
|
133
|
+
expect(user.cell("c3")?.code).toBe("another writer");
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
test.for(["response", "document"] as const)(
|
|
138
|
+
"does not resurrect a cancelled creation when the %s arrives first",
|
|
139
|
+
async (first, { expect, session: { user, host } }) => {
|
|
140
|
+
user.createCell({ id: "new", code: "new cell" });
|
|
141
|
+
const flushed = user.sync();
|
|
142
|
+
const creation = await host.nextRequest();
|
|
143
|
+
user.deleteCell("new");
|
|
144
|
+
host.changeCell("c1", { code: "unrelated edit" });
|
|
145
|
+
host.deliverDocument();
|
|
146
|
+
creation.process();
|
|
147
|
+
const deliver = {
|
|
148
|
+
response: () => creation.deliverResponse(),
|
|
149
|
+
document: () => host.deliverDocument(),
|
|
150
|
+
};
|
|
151
|
+
deliver[first]();
|
|
152
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
153
|
+
expect(user.cell("new")).toBeUndefined();
|
|
154
|
+
expect(user.order()).toEqual(["c1", "c3", "k7f2"]);
|
|
155
|
+
deliver[first === "response" ? "document" : "response"]();
|
|
156
|
+
const deletion = await host.nextRequest();
|
|
157
|
+
deletion.process();
|
|
158
|
+
deletion.deliverResponse();
|
|
159
|
+
host.deliverDocument();
|
|
160
|
+
await expect(flushed).resolves.toEqual(host.document().revision);
|
|
161
|
+
expect(host.document().cells.has("new")).toBe(false);
|
|
162
|
+
expect(user.cell("new")).toBeUndefined();
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
test("releases an edit undone before sending and follows subsequent host changes", async ({
|
|
167
|
+
expect,
|
|
168
|
+
session: { user, host, notebook },
|
|
169
|
+
}) => {
|
|
170
|
+
user.changeCode("c3", "temporary");
|
|
171
|
+
user.changeCode("c3", "x = 21");
|
|
172
|
+
expect(user.status()).toEqual({ status: "idle" });
|
|
173
|
+
await expect(user.sync()).resolves.toEqual(host.document().revision);
|
|
174
|
+
|
|
175
|
+
host.changeCell("c3", { code: "another writer" });
|
|
176
|
+
host.deliverDocument();
|
|
177
|
+
expect(user.cell("c3")?.code).toBe("another writer");
|
|
178
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
179
|
+
expect(notebook.edit).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("flushes edits made while an earlier flush is finishing", async ({
|
|
183
|
+
expect,
|
|
184
|
+
session: { user, host, notebook },
|
|
185
|
+
}) => {
|
|
186
|
+
const original = host.document().revision;
|
|
187
|
+
const first = user.sync();
|
|
188
|
+
// Let the first flush drain without awaiting its returned promise.
|
|
189
|
+
await Promise.resolve();
|
|
190
|
+
user.changeCode("c3", "new code");
|
|
191
|
+
const second = user.sync();
|
|
192
|
+
const settled = vi.fn();
|
|
193
|
+
void second.then(settled, settled);
|
|
194
|
+
|
|
195
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
196
|
+
expect(notebook.edit).toHaveBeenCalledOnce();
|
|
197
|
+
expect(settled).not.toHaveBeenCalled();
|
|
198
|
+
const request = await host.nextRequest();
|
|
199
|
+
request.process();
|
|
200
|
+
expect(host.document().cells.get("c3")?.code).toBe("new code");
|
|
201
|
+
request.deliverResponse();
|
|
202
|
+
host.deliverDocument();
|
|
203
|
+
await expect(second).resolves.toEqual(host.document().revision);
|
|
204
|
+
await expect(first).resolves.toEqual(original);
|
|
205
|
+
});
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type DocumentEdit,
|
|
9
9
|
type DocumentEditor,
|
|
10
10
|
} from "../state";
|
|
11
|
+
import { selectCell, selectOrder } from "../select";
|
|
11
12
|
import { cell, document, withCells } from "./fixtures";
|
|
12
13
|
|
|
13
14
|
const lifetimes: AbortController[] = [];
|
|
@@ -29,6 +30,8 @@ function setup(initial: Document | null = document()) {
|
|
|
29
30
|
store,
|
|
30
31
|
source,
|
|
31
32
|
...model,
|
|
33
|
+
cellAtom: (id: string) => selectCell(model.documentAtom, id),
|
|
34
|
+
orderAtom: selectOrder(model.documentAtom),
|
|
32
35
|
abort: () => controller.abort(),
|
|
33
36
|
edit: (callback: DocumentEdit) => store.set(model.editAtom, callback),
|
|
34
37
|
};
|
|
@@ -252,10 +255,9 @@ describe("document model", () => {
|
|
|
252
255
|
unsubscribeOrder();
|
|
253
256
|
});
|
|
254
257
|
|
|
255
|
-
it("isolates cell
|
|
258
|
+
it("isolates cell and order subscriptions across local and host updates", () => {
|
|
256
259
|
const initial = document();
|
|
257
|
-
const { store, source, cellAtom, orderAtom,
|
|
258
|
-
setup(initial);
|
|
260
|
+
const { store, source, cellAtom, orderAtom, edit } = setup(initial);
|
|
259
261
|
edit((tx) => {
|
|
260
262
|
tx.setCode("c1", "local");
|
|
261
263
|
tx.createCell({ id: "c3", code: "new" });
|
|
@@ -263,16 +265,13 @@ describe("document model", () => {
|
|
|
263
265
|
const c1 = cellAtom("c1");
|
|
264
266
|
const first = store.get(c1);
|
|
265
267
|
const order = store.get(orderAtom);
|
|
266
|
-
const config = store.get(appConfigAtom);
|
|
267
268
|
const onFirst = vi.fn();
|
|
268
269
|
const onSecond = vi.fn();
|
|
269
270
|
const onOrder = vi.fn();
|
|
270
|
-
const onConfig = vi.fn();
|
|
271
271
|
const unsubscribe = [
|
|
272
272
|
store.sub(c1, onFirst),
|
|
273
273
|
store.sub(cellAtom("c2"), onSecond),
|
|
274
274
|
store.sub(orderAtom, onOrder),
|
|
275
|
-
store.sub(appConfigAtom, onConfig),
|
|
276
275
|
];
|
|
277
276
|
|
|
278
277
|
edit((tx) => tx.setCode("c2", "other local"));
|
|
@@ -289,11 +288,9 @@ describe("document model", () => {
|
|
|
289
288
|
});
|
|
290
289
|
expect(store.get(c1)).toBe(first);
|
|
291
290
|
expect(store.get(orderAtom)).toBe(order);
|
|
292
|
-
expect(store.get(appConfigAtom)).toBe(config);
|
|
293
291
|
expect(onFirst).not.toHaveBeenCalled();
|
|
294
292
|
expect(onSecond).toHaveBeenCalledOnce();
|
|
295
293
|
expect(onOrder).not.toHaveBeenCalled();
|
|
296
|
-
expect(onConfig).not.toHaveBeenCalled();
|
|
297
294
|
|
|
298
295
|
store.set(
|
|
299
296
|
source,
|
|
@@ -311,11 +308,6 @@ describe("document model", () => {
|
|
|
311
308
|
edit((tx) => tx.setOrder(["c3", "c2", "c1"]));
|
|
312
309
|
expect(onOrder).toHaveBeenCalledOnce();
|
|
313
310
|
expect(onFirst).toHaveBeenCalledOnce();
|
|
314
|
-
store.set(source, {
|
|
315
|
-
...remote,
|
|
316
|
-
revision: { ...remote.revision, app_config: { width: "full" } },
|
|
317
|
-
});
|
|
318
|
-
expect(onConfig).toHaveBeenCalledOnce();
|
|
319
311
|
unsubscribe.forEach((fn) => fn());
|
|
320
312
|
});
|
|
321
313
|
|