@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
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import type { Result } from "@marimo-team/client";
|
|
5
|
+
import * as Protocol from "@marimo-team/client/protocol";
|
|
6
|
+
import type { Document } from "../../host/resources/document";
|
|
7
|
+
|
|
8
|
+
export interface EditRequest {
|
|
9
|
+
readonly notebookId: Protocol.NotebookId;
|
|
10
|
+
readonly changes: readonly Protocol.Change[];
|
|
11
|
+
readonly idempotencyKey: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Outcome {
|
|
15
|
+
readonly result: Result<Protocol.EditResponseOutput>;
|
|
16
|
+
/** Only a new commit produces a stream update; replaying a receipt does not. */
|
|
17
|
+
readonly update?: Document;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const hash = (text: string) =>
|
|
21
|
+
createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
22
|
+
|
|
23
|
+
function record(
|
|
24
|
+
input: Protocol.CellInput,
|
|
25
|
+
notebookId: Protocol.NotebookId,
|
|
26
|
+
version: number,
|
|
27
|
+
): Protocol.Cell {
|
|
28
|
+
const cell = Protocol.CellInput.parse(input);
|
|
29
|
+
const config = Protocol.CellConfigInput.parse(cell.config ?? {});
|
|
30
|
+
const code = cell.code.replace(/\r\n?/g, "\n");
|
|
31
|
+
const codeHash = hash(code);
|
|
32
|
+
return {
|
|
33
|
+
id: cell.id,
|
|
34
|
+
notebook_id: notebookId,
|
|
35
|
+
name: cell.name,
|
|
36
|
+
code,
|
|
37
|
+
config,
|
|
38
|
+
code_hash: codeHash,
|
|
39
|
+
etag: hash(
|
|
40
|
+
[
|
|
41
|
+
codeHash,
|
|
42
|
+
cell.name,
|
|
43
|
+
...[
|
|
44
|
+
config.column,
|
|
45
|
+
config.disabled,
|
|
46
|
+
config.hide_code,
|
|
47
|
+
config.expand_output,
|
|
48
|
+
].map((value) => JSON.stringify(value)),
|
|
49
|
+
].join("\n"),
|
|
50
|
+
),
|
|
51
|
+
version,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function rejected(
|
|
56
|
+
status: number,
|
|
57
|
+
kind: string,
|
|
58
|
+
detail: string,
|
|
59
|
+
edit?: Protocol.Problem["edit"],
|
|
60
|
+
): Outcome {
|
|
61
|
+
return {
|
|
62
|
+
result: {
|
|
63
|
+
ok: false,
|
|
64
|
+
status,
|
|
65
|
+
problem: {
|
|
66
|
+
type: `tag:marimo.io,2026:host/${kind}`,
|
|
67
|
+
title: kind === "conflict" ? "Conflict" : "Invalid request",
|
|
68
|
+
detail,
|
|
69
|
+
status,
|
|
70
|
+
...(edit === undefined ? {} : { edit }),
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** An independent implementation of the document edit protocol, without client draft logic. */
|
|
77
|
+
export function createTestHost(initial: Document) {
|
|
78
|
+
let current = initial;
|
|
79
|
+
const receipts = new Map<
|
|
80
|
+
string,
|
|
81
|
+
{
|
|
82
|
+
fingerprint: string;
|
|
83
|
+
result: Result<Protocol.EditResponseOutput>;
|
|
84
|
+
expiresAt: number;
|
|
85
|
+
}
|
|
86
|
+
>();
|
|
87
|
+
|
|
88
|
+
function apply(changes: readonly Protocol.Change[]): Outcome {
|
|
89
|
+
const cells = new Map(current.cells);
|
|
90
|
+
let order = [...current.revision.order];
|
|
91
|
+
const touched = new Set<Protocol.CellId>();
|
|
92
|
+
const version = current.revision.version + 1;
|
|
93
|
+
const matches = (id: Protocol.CellId, etag: string) =>
|
|
94
|
+
etag === "*" || current.cells.get(id)?.etag === etag;
|
|
95
|
+
const conflict = (change: number, id?: Protocol.CellId) =>
|
|
96
|
+
rejected(409, "conflict", `Edit conflict at change ${change}`, {
|
|
97
|
+
change,
|
|
98
|
+
cell: id === undefined ? null : (current.cells.get(id) ?? null),
|
|
99
|
+
document: current.revision,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
for (const [index, change] of changes.entries()) {
|
|
103
|
+
switch (change.kind) {
|
|
104
|
+
case "put_cell": {
|
|
105
|
+
const { id } = change.cell;
|
|
106
|
+
if (change.base_etag === null) {
|
|
107
|
+
if (current.cells.has(id) || cells.has(id)) {
|
|
108
|
+
return conflict(index, id);
|
|
109
|
+
}
|
|
110
|
+
if (change.after !== undefined && change.before !== undefined) {
|
|
111
|
+
return rejected(
|
|
112
|
+
400,
|
|
113
|
+
"invalid-request",
|
|
114
|
+
"A cell cannot name both before and after",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const anchor = change.after ?? change.before;
|
|
118
|
+
if (anchor !== undefined && !order.includes(anchor)) {
|
|
119
|
+
return rejected(
|
|
120
|
+
400,
|
|
121
|
+
"invalid-request",
|
|
122
|
+
"The placement anchor does not exist",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const position =
|
|
126
|
+
anchor === undefined
|
|
127
|
+
? order.length
|
|
128
|
+
: order.indexOf(anchor) + (change.after === undefined ? 0 : 1);
|
|
129
|
+
order.splice(position, 0, id);
|
|
130
|
+
} else if (!cells.has(id) || !matches(id, change.base_etag)) {
|
|
131
|
+
return conflict(index, id);
|
|
132
|
+
}
|
|
133
|
+
cells.set(
|
|
134
|
+
id,
|
|
135
|
+
record(change.cell, current.revision.notebook_id, version),
|
|
136
|
+
);
|
|
137
|
+
touched.add(id);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case "delete_cell":
|
|
141
|
+
if (!cells.has(change.id) || !matches(change.id, change.base_etag)) {
|
|
142
|
+
return conflict(index, change.id);
|
|
143
|
+
}
|
|
144
|
+
cells.delete(change.id);
|
|
145
|
+
order = order.filter((id) => id !== change.id);
|
|
146
|
+
touched.add(change.id);
|
|
147
|
+
break;
|
|
148
|
+
case "set_order":
|
|
149
|
+
if (
|
|
150
|
+
change.base_etag !== "*" &&
|
|
151
|
+
change.base_etag !== current.revision.order_etag
|
|
152
|
+
) {
|
|
153
|
+
return conflict(index);
|
|
154
|
+
}
|
|
155
|
+
if (
|
|
156
|
+
change.order.length !== cells.size ||
|
|
157
|
+
new Set(change.order).size !== cells.size ||
|
|
158
|
+
change.order.some((id) => !cells.has(id))
|
|
159
|
+
) {
|
|
160
|
+
return rejected(
|
|
161
|
+
400,
|
|
162
|
+
"invalid-request",
|
|
163
|
+
"Order must name every cell exactly once",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
order = [...change.order];
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const [id, cell] of cells) {
|
|
172
|
+
const previous = current.cells.get(id);
|
|
173
|
+
if (previous?.etag === cell.etag) {
|
|
174
|
+
cells.set(id, previous);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const orderEtag = hash(JSON.stringify(order));
|
|
178
|
+
const etag = hash(
|
|
179
|
+
[
|
|
180
|
+
orderEtag,
|
|
181
|
+
current.revision.app_config_etag,
|
|
182
|
+
...order.map((id) => cells.get(id)!.etag),
|
|
183
|
+
].join("\n"),
|
|
184
|
+
);
|
|
185
|
+
const changed = etag !== current.revision.etag;
|
|
186
|
+
if (changed) {
|
|
187
|
+
current = {
|
|
188
|
+
revision: {
|
|
189
|
+
...current.revision,
|
|
190
|
+
order,
|
|
191
|
+
order_etag: orderEtag,
|
|
192
|
+
etag,
|
|
193
|
+
version,
|
|
194
|
+
},
|
|
195
|
+
cells,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const versions: Protocol.EditResponseOutput["cells"] = {};
|
|
199
|
+
for (const id of touched) {
|
|
200
|
+
const cell = current.cells.get(id);
|
|
201
|
+
versions[id] =
|
|
202
|
+
cell === undefined
|
|
203
|
+
? null
|
|
204
|
+
: {
|
|
205
|
+
etag: cell.etag,
|
|
206
|
+
code_hash: cell.code_hash,
|
|
207
|
+
version: cell.version,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
result: {
|
|
212
|
+
ok: true,
|
|
213
|
+
status: 200,
|
|
214
|
+
data: { document: current.revision, cells: versions },
|
|
215
|
+
},
|
|
216
|
+
...(changed ? { update: current } : {}),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
document: () => current,
|
|
222
|
+
apply,
|
|
223
|
+
process(request: EditRequest): Outcome {
|
|
224
|
+
const fingerprint = JSON.stringify([request.notebookId, request.changes]);
|
|
225
|
+
const receipt = receipts.get(request.idempotencyKey);
|
|
226
|
+
if (receipt !== undefined && Date.now() < receipt.expiresAt) {
|
|
227
|
+
return receipt.fingerprint === fingerprint
|
|
228
|
+
? { result: receipt.result }
|
|
229
|
+
: rejected(
|
|
230
|
+
409,
|
|
231
|
+
"conflict",
|
|
232
|
+
"Idempotency key was used for another request",
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const outcome =
|
|
236
|
+
request.notebookId === current.revision.notebook_id
|
|
237
|
+
? apply(request.changes)
|
|
238
|
+
: rejected(404, "not-found", "Notebook does not exist");
|
|
239
|
+
receipts.set(request.idempotencyKey, {
|
|
240
|
+
fingerprint,
|
|
241
|
+
result: outcome.result,
|
|
242
|
+
expiresAt: Date.now() + 300_000,
|
|
243
|
+
});
|
|
244
|
+
return outcome;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type { Result } from "@marimo-team/client";
|
|
4
|
+
import type * as Protocol from "@marimo-team/client/protocol";
|
|
5
|
+
import { atom, type createStore } from "jotai/vanilla";
|
|
6
|
+
import { vi } from "vitest";
|
|
7
|
+
import { Deferred } from "@/utils/Deferred";
|
|
8
|
+
import { invariant } from "@/utils/invariant";
|
|
9
|
+
import type { Document } from "../../host/resources/document";
|
|
10
|
+
import type { DocumentHost } from "../model";
|
|
11
|
+
import { createTestHost, type EditRequest } from "./test-host";
|
|
12
|
+
|
|
13
|
+
export interface PendingRequest extends EditRequest {
|
|
14
|
+
readonly signal: AbortSignal;
|
|
15
|
+
process(): void;
|
|
16
|
+
deliverResponse(): void;
|
|
17
|
+
loseResponse(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createTestNotebook(
|
|
21
|
+
store: ReturnType<typeof createStore>,
|
|
22
|
+
initial: Document,
|
|
23
|
+
) {
|
|
24
|
+
const host = createTestHost(initial);
|
|
25
|
+
const source = atom<Document | null>(initial);
|
|
26
|
+
const requests: PendingRequest[] = [];
|
|
27
|
+
const waiting: Deferred<PendingRequest>[] = [];
|
|
28
|
+
const documents: Document[] = [];
|
|
29
|
+
function apply(changes: readonly Protocol.Change[]) {
|
|
30
|
+
const { result, update } = host.apply(changes);
|
|
31
|
+
invariant(result.ok, "Another writer's edit was rejected");
|
|
32
|
+
if (update !== undefined) {
|
|
33
|
+
documents.push(update);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const edit = vi.fn<DocumentHost["edit"]>((notebookId, changes, options) => {
|
|
37
|
+
options.signal.throwIfAborted();
|
|
38
|
+
const reply = new Deferred<Result<Protocol.EditResponseOutput>>();
|
|
39
|
+
let outcome: Result<Protocol.EditResponseOutput> | undefined;
|
|
40
|
+
const abort = () => reply.reject(options.signal.reason);
|
|
41
|
+
const finish = () => options.signal.removeEventListener("abort", abort);
|
|
42
|
+
options.signal.addEventListener("abort", abort, { once: true });
|
|
43
|
+
void reply.promise.then(finish, finish);
|
|
44
|
+
const request: PendingRequest = {
|
|
45
|
+
notebookId,
|
|
46
|
+
changes: structuredClone(changes),
|
|
47
|
+
idempotencyKey: options.idempotencyKey,
|
|
48
|
+
signal: options.signal,
|
|
49
|
+
process() {
|
|
50
|
+
invariant(outcome === undefined, "Request was already processed");
|
|
51
|
+
// Cancellation of the client's wait does not roll back a server commit.
|
|
52
|
+
const processed = host.process(request);
|
|
53
|
+
outcome = processed.result;
|
|
54
|
+
if (processed.update !== undefined) {
|
|
55
|
+
documents.push(processed.update);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
deliverResponse() {
|
|
59
|
+
invariant(
|
|
60
|
+
outcome !== undefined,
|
|
61
|
+
"Process the request before delivering its response",
|
|
62
|
+
);
|
|
63
|
+
reply.resolve(outcome);
|
|
64
|
+
},
|
|
65
|
+
loseResponse() {
|
|
66
|
+
reply.reject(new Error("Connection lost"));
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
const next = waiting.shift();
|
|
70
|
+
if (next === undefined) {
|
|
71
|
+
requests.push(request);
|
|
72
|
+
} else {
|
|
73
|
+
next.resolve(request);
|
|
74
|
+
}
|
|
75
|
+
return reply.promise;
|
|
76
|
+
});
|
|
77
|
+
const notebook = { documentAtom: source, edit } satisfies DocumentHost;
|
|
78
|
+
return {
|
|
79
|
+
notebook,
|
|
80
|
+
control: {
|
|
81
|
+
document: host.document,
|
|
82
|
+
nextRequest(): Promise<PendingRequest> {
|
|
83
|
+
const next = requests.shift();
|
|
84
|
+
if (next !== undefined) {
|
|
85
|
+
return Promise.resolve(next);
|
|
86
|
+
}
|
|
87
|
+
const deferred = new Deferred<PendingRequest>();
|
|
88
|
+
waiting.push(deferred);
|
|
89
|
+
return deferred.promise;
|
|
90
|
+
},
|
|
91
|
+
changeCell(
|
|
92
|
+
id: Protocol.CellId,
|
|
93
|
+
patch: Partial<Omit<Protocol.CellInput, "id">>,
|
|
94
|
+
) {
|
|
95
|
+
const cell = host.document().cells.get(id);
|
|
96
|
+
invariant(
|
|
97
|
+
cell !== undefined && cell.code !== null,
|
|
98
|
+
`No host source for cell ${id}`,
|
|
99
|
+
);
|
|
100
|
+
apply([
|
|
101
|
+
{
|
|
102
|
+
kind: "put_cell",
|
|
103
|
+
base_etag: cell.etag,
|
|
104
|
+
cell: {
|
|
105
|
+
...cell,
|
|
106
|
+
code: cell.code,
|
|
107
|
+
...patch,
|
|
108
|
+
config: { ...cell.config, ...patch.config },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
]);
|
|
112
|
+
},
|
|
113
|
+
createCell(cell: Protocol.CellInput) {
|
|
114
|
+
apply([{ kind: "put_cell", base_etag: null, cell }]);
|
|
115
|
+
},
|
|
116
|
+
deleteCell(id: Protocol.CellId) {
|
|
117
|
+
const cell = host.document().cells.get(id);
|
|
118
|
+
invariant(cell !== undefined, `No host cell ${id}`);
|
|
119
|
+
apply([{ kind: "delete_cell", id, base_etag: cell.etag }]);
|
|
120
|
+
},
|
|
121
|
+
deliverDocument() {
|
|
122
|
+
const next = documents.shift();
|
|
123
|
+
invariant(
|
|
124
|
+
next !== undefined,
|
|
125
|
+
"No document update is waiting for delivery",
|
|
126
|
+
);
|
|
127
|
+
store.set(source, next);
|
|
128
|
+
},
|
|
129
|
+
reset() {
|
|
130
|
+
documents.length = 0;
|
|
131
|
+
store.set(source, null);
|
|
132
|
+
},
|
|
133
|
+
deliverSnapshot() {
|
|
134
|
+
documents.length = 0;
|
|
135
|
+
store.set(source, host.document());
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type * as Protocol from "@marimo-team/client/protocol";
|
|
4
|
+
import type { createStore } from "jotai/vanilla";
|
|
5
|
+
import type { DocumentModel } from "../model";
|
|
6
|
+
|
|
7
|
+
/** Translate user actions and observations without keeping any expected state. */
|
|
8
|
+
export function createUser(
|
|
9
|
+
store: ReturnType<typeof createStore>,
|
|
10
|
+
model: DocumentModel,
|
|
11
|
+
) {
|
|
12
|
+
function observe(promise: Promise<Protocol.Document>) {
|
|
13
|
+
// A failed assertion may end a scenario before it awaits sync; teardown
|
|
14
|
+
// should not obscure that failure with an unhandled abort rejection.
|
|
15
|
+
void promise.catch(() => undefined);
|
|
16
|
+
return promise;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
changeCode(id: Protocol.CellId, code: string) {
|
|
20
|
+
store.set(model.editAtom, (tx) => tx.setCode(id, code));
|
|
21
|
+
},
|
|
22
|
+
changeCells(
|
|
23
|
+
changes: Record<Protocol.CellId, Partial<Omit<Protocol.CellInput, "id">>>,
|
|
24
|
+
) {
|
|
25
|
+
store.set(model.editAtom, (tx) => {
|
|
26
|
+
for (const [id, patch] of Object.entries(changes)) {
|
|
27
|
+
if (patch.code !== undefined) {
|
|
28
|
+
tx.setCode(id, patch.code);
|
|
29
|
+
}
|
|
30
|
+
if (patch.name !== undefined) {
|
|
31
|
+
tx.setName(id, patch.name);
|
|
32
|
+
}
|
|
33
|
+
if (patch.config !== undefined) {
|
|
34
|
+
tx.updateConfig(id, patch.config);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
createCell(cell: Protocol.CellInput) {
|
|
40
|
+
store.set(model.editAtom, (tx) => tx.createCell(cell));
|
|
41
|
+
},
|
|
42
|
+
deleteCell(id: Protocol.CellId) {
|
|
43
|
+
store.set(model.editAtom, (tx) => tx.deleteCell(id));
|
|
44
|
+
},
|
|
45
|
+
cell(id: Protocol.CellId) {
|
|
46
|
+
const cell = store.get(model.cellAtom(id));
|
|
47
|
+
return cell === undefined
|
|
48
|
+
? undefined
|
|
49
|
+
: {
|
|
50
|
+
id: cell.id,
|
|
51
|
+
code: cell.code,
|
|
52
|
+
name: cell.name,
|
|
53
|
+
config: cell.config,
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
order: () => store.get(model.orderAtom),
|
|
57
|
+
status: () => store.get(model.syncStateAtom),
|
|
58
|
+
sync: () => observe(model.flush()),
|
|
59
|
+
retry: () => observe(model.retry()),
|
|
60
|
+
};
|
|
61
|
+
}
|