@marimo-team/islands 0.25.3-dev308 → 0.25.3-dev310

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.
Files changed (106) hide show
  1. package/dist/{ConnectedDataExplorerComponent-DJnSxHJB.js → ConnectedDataExplorerComponent-DieU6L4x.js} +1 -1
  2. package/dist/{ErrorBoundary-flfhLFje.js → ErrorBoundary-DjoeT4kv.js} +1 -1
  3. package/dist/{abnfDiagram-VCTEODGH-CR0U5hup.js → abnfDiagram-VCTEODGH-LOVrjbdp.js} +2 -2
  4. package/dist/{any-language-editor-C5dsTpeB.js → any-language-editor-DMC2JE9_.js} +1 -1
  5. package/dist/{architectureDiagram-5GKGNRK7-Bc_fbI1Q.js → architectureDiagram-5GKGNRK7-RZsm312h.js} +1 -1
  6. package/dist/assets/__vite-browser-external-BSp9tJ_k.js +1 -0
  7. package/dist/assets/worker-BdfPA0g5.js +171 -0
  8. package/dist/{chat-ui-C4iLRzY0.js → chat-ui-DkuxGEDp.js} +418 -418
  9. package/dist/{chunk-IMKFNOWR-y9z5oeAY.js → chunk-IMKFNOWR-B6agbLjo.js} +1 -1
  10. package/dist/{chunk-SHT3W25Y-Tu-V-e_v.js → chunk-SHT3W25Y-CfULafYG.js} +1 -1
  11. package/dist/{chunk-SVP7TREG-CJFxs5R_.js → chunk-SVP7TREG-DEOpPMJp.js} +1 -1
  12. package/dist/{chunk-TICWLB2K-zkTP7ya7.js → chunk-TICWLB2K-DaH-gkX5.js} +1 -1
  13. package/dist/{classDiagram-ZZMXUADV-DxJAZI0N.js → classDiagram-ZZMXUADV-C0CyiWQ7.js} +2 -2
  14. package/dist/{classDiagram-v2-VYDZK3BY-TmHo2BpS.js → classDiagram-v2-VYDZK3BY-xfIc9e2Y.js} +2 -2
  15. package/dist/{code-visibility-BDs_r7jH.js → code-visibility-CduMVVFS.js} +32 -32
  16. package/dist/{common-CMTsL35V.js → common-CMcEcucH.js} +919 -919
  17. package/dist/{config-BG2qKzDg.js → config-BVYA1P0C.js} +103 -76
  18. package/dist/{cynefinDiagram-5FMLGOSQ-Eb40hmhD.js → cynefinDiagram-5FMLGOSQ-DwGYhKu0.js} +1 -1
  19. package/dist/{dagre-GXQ25YYZ-_paaVwit.js → dagre-GXQ25YYZ-DfMaWJOV.js} +1 -1
  20. package/dist/{data-grid-overlay-editor-es6V6qf-.js → data-grid-overlay-editor-BYrIVut3.js} +1 -1
  21. package/dist/{diagram-S7CK7UJ4-BtKJm1_4.js → diagram-S7CK7UJ4-Bw_L87GZ.js} +1 -1
  22. package/dist/{diagram-UQ7AKVKN-DQbxhEMd.js → diagram-UQ7AKVKN-nsrs3Fbz.js} +1 -1
  23. package/dist/{diagram-VX7I27RA-DITDIfGC.js → diagram-VX7I27RA-7ST9lEqP.js} +1 -1
  24. package/dist/{diagram-Z3DM3KII-Ct9_zo6F.js → diagram-Z3DM3KII-BnGte-HX.js} +1 -1
  25. package/dist/{ebnfDiagram-PWID7BFC-6cOntwfv.js → ebnfDiagram-PWID7BFC-zaKyJ1YL.js} +2 -2
  26. package/dist/{erDiagram-RLTQ6QDP-CJiOER41.js → erDiagram-RLTQ6QDP-Cx7g95FZ.js} +1 -1
  27. package/dist/{error-banner-DGOrmBk5.js → error-banner-DkQNU4bn.js} +128 -128
  28. package/dist/{error-entries-BWmleiL0.js → error-entries-istW7Xbs.js} +589 -544
  29. package/dist/{feedback-button-D4eVjeNs.js → feedback-button-Zvm26aOQ.js} +107 -106
  30. package/dist/{flowDiagram-HODETNUW-1lPBEUuW.js → flowDiagram-HODETNUW-C7nIqaHP.js} +2 -2
  31. package/dist/{formats-qhBmYb0l.js → formats-CMa7L3E8.js} +1 -1
  32. package/dist/{glide-data-editor-ahfaPdFW.js → glide-data-editor-BE-GgVp4.js} +3 -3
  33. package/dist/{globals-m8oswz--.js → globals-Dbru-WLP.js} +1 -1
  34. package/dist/{html-to-image-B8hE6HIH.js → html-to-image-pPbR2cDx.js} +4512 -4464
  35. package/dist/{infoDiagram-27XIBGKW-C6K4tri5.js → infoDiagram-27XIBGKW-BTM_NXsn.js} +1 -1
  36. package/dist/{ishikawaDiagram-5VMMS53U-DwcwgRNH.js → ishikawaDiagram-5VMMS53U-K0FZrlvv.js} +1 -1
  37. package/dist/{kanban-definition-UXKFOSKX-E489OMdc.js → kanban-definition-UXKFOSKX-ftznOyrz.js} +1 -1
  38. package/dist/main.js +6595 -6186
  39. package/dist/{mermaid-DMPMkVqY.js → mermaid-CNgH43ED.js} +33 -33
  40. package/dist/{mindmap-definition-YA3MSWOX-DWHjasOp.js → mindmap-definition-YA3MSWOX-CmzbxKfM.js} +1 -1
  41. package/dist/{number-overlay-editor-C6klKzEr.js → number-overlay-editor-qvlPZ-l-.js} +1 -1
  42. package/dist/{pegDiagram-XKGWAZYB-COjcPOZT.js → pegDiagram-XKGWAZYB-DfTAWfGE.js} +2 -2
  43. package/dist/{pieDiagram-E7YTZNPT-BHAVZ38h.js → pieDiagram-E7YTZNPT-_kGQstZj.js} +1 -1
  44. package/dist/{process-output-DZDL_j1a.js → process-output-BySO1nIs.js} +3 -3
  45. package/dist/{railroadDiagram-O6MQD6OU-94ThFyKX.js → railroadDiagram-O6MQD6OU-D41WmIff.js} +2 -2
  46. package/dist/{requirementDiagram-BXWQKSXE-CpF-Fxbb.js → requirementDiagram-BXWQKSXE-Bh53ZI3I.js} +1 -1
  47. package/dist/{reveal-component-DwnxPyVz.js → reveal-component-DQaegdcW.js} +4 -4
  48. package/dist/{sequenceDiagram-WJ2MYXX4-CvANK2x7.js → sequenceDiagram-WJ2MYXX4-DBX27dNm.js} +1 -1
  49. package/dist/{stateDiagram-D77RDMKH-WHuJ4mJL.js → stateDiagram-D77RDMKH-CSYz2IZi.js} +2 -2
  50. package/dist/{stateDiagram-v2-MP3YSRHH-DbO7lwYq.js → stateDiagram-v2-MP3YSRHH-DVLvtOqn.js} +2 -2
  51. package/dist/{swimlanes-42K2YHIH-CS2s29Vo.js → swimlanes-42K2YHIH-D59N0ktA.js} +1 -1
  52. package/dist/{swimlanesDiagram-VR7AAH4N-CXaA1vQg.js → swimlanesDiagram-VR7AAH4N-CRgpmVSW.js} +2 -2
  53. package/dist/{timeline-definition-24CTP7MA-B-SQMg5h.js → timeline-definition-24CTP7MA-C_xIIZDM.js} +1 -1
  54. package/dist/toDate-5qPtZ6HL.js +94 -0
  55. package/dist/{vega-component-DwqGfN3_.js → vega-component-Bly2lDhz.js} +10 -10
  56. package/dist/{vennDiagram-4TSXK5OY-etEFgvaG.js → vennDiagram-4TSXK5OY-DlOf0Mjs.js} +1 -1
  57. package/dist/{wardleyDiagram-VM6X3IG4-BEQN7g62.js → wardleyDiagram-VM6X3IG4-zD_2guVe.js} +1 -1
  58. package/dist/{xychartDiagram-S5SC5T6Z-D8-e5Z0R.js → xychartDiagram-S5SC5T6Z-CA0Dy1ZM.js} +1 -1
  59. package/package.json +1 -1
  60. package/src/__mocks__/executions.ts +35 -13
  61. package/src/__mocks__/requests.ts +0 -1
  62. package/src/components/editor/cell/__tests__/useRunCells.test.tsx +86 -101
  63. package/src/components/editor/cell/useRunCells.ts +38 -65
  64. package/src/components/editor/notebook-cell.tsx +32 -20
  65. package/src/components/editor/output/ansi-reduce.ts +7 -0
  66. package/src/core/ai/__tests__/staged-cells.test.ts +1 -1
  67. package/src/core/cells/__tests__/__snapshots__/cells.test.ts.snap +2 -0
  68. package/src/core/cells/__tests__/cells.test.ts +0 -14
  69. package/src/core/cells/__tests__/collapseConsoleOutputs.test.ts +15 -0
  70. package/src/core/cells/__tests__/utils.test.ts +1 -1
  71. package/src/core/cells/add-missing-import.ts +2 -5
  72. package/src/core/cells/cell.ts +1 -0
  73. package/src/core/cells/cells.ts +1 -2
  74. package/src/core/executions/__tests__/atoms.test.ts +227 -43
  75. package/src/core/executions/__tests__/hooks.test.tsx +1 -2
  76. package/src/core/executions/__tests__/input.test.ts +86 -0
  77. package/src/core/executions/__tests__/run.test.ts +390 -0
  78. package/src/core/executions/__tests__/select.test.ts +105 -30
  79. package/src/core/executions/atoms.ts +186 -71
  80. package/src/core/executions/index.ts +7 -6
  81. package/src/core/executions/input.ts +55 -0
  82. package/src/core/executions/run.ts +120 -0
  83. package/src/core/executions/select.ts +59 -24
  84. package/src/core/islands/__tests__/bridge.test.ts +35 -54
  85. package/src/core/islands/bootstrap.ts +5 -1
  86. package/src/core/islands/bridge.ts +16 -28
  87. package/src/core/islands/components/IslandControls.tsx +15 -7
  88. package/src/core/islands/components/__tests__/IslandControls.test.tsx +36 -53
  89. package/src/core/islands/worker/worker.tsx +45 -19
  90. package/src/core/network/__tests__/requests-lazy.test.ts +14 -24
  91. package/src/core/network/__tests__/requests-toasting.test.tsx +7 -7
  92. package/src/core/network/requests-lazy.ts +2 -3
  93. package/src/core/network/requests-network.ts +0 -9
  94. package/src/core/network/requests-static.ts +0 -1
  95. package/src/core/network/requests-toasting.tsx +0 -1
  96. package/src/core/network/types.ts +0 -2
  97. package/src/core/notebook/replica/__tests__/fixtures.ts +17 -1
  98. package/src/core/notebook/replica/backend.ts +3 -1
  99. package/src/core/runtime/__tests__/runtime.test.ts +36 -0
  100. package/src/core/runtime/config.ts +36 -1
  101. package/src/core/runtime/runtime.ts +21 -1
  102. package/src/core/transports/static.ts +4 -3
  103. package/src/core/wasm/bridge.ts +0 -17
  104. package/dist/assets/__vite-browser-external-DzTYIHH_.js +0 -1
  105. package/dist/assets/worker-BH08I2Il.js +0 -171
  106. package/dist/static-state-Dtocd9UO.js +0 -68
@@ -0,0 +1,390 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type * as Protocol from "@marimo-team/client/protocol";
4
+ import { atom } from "jotai";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { MockExecutions } from "@/__mocks__/executions";
7
+ import { cellId } from "@/__tests__/branded";
8
+ import type { DocumentModel, EditableDocument } from "@/core/notebook/document";
9
+ import {
10
+ cell,
11
+ CONFIG,
12
+ document as documentOf,
13
+ withCells,
14
+ } from "@/core/notebook/document/__tests__/fixtures";
15
+ import {
16
+ ConnectionClosedReason,
17
+ ConnectionState,
18
+ } from "@/core/notebook/replica";
19
+ import {
20
+ type FakeRun,
21
+ fakeBackend,
22
+ } from "@/core/notebook/replica/__tests__/fixtures";
23
+ import { activeDocumentModelAtom } from "@/core/session/active-notebook";
24
+ import { store } from "@/core/state/jotai";
25
+ import { cellExecutionInputsAtom } from "../atoms";
26
+ import { runCells } from "../run";
27
+ import { cellExecutionView } from "../select";
28
+
29
+ const toast = vi.fn();
30
+ vi.mock("@/components/ui/use-toast", () => ({
31
+ toast: (...args: unknown[]) => toast(...args),
32
+ }));
33
+ vi.mock("@/core/runtime/config", () => ({
34
+ startRuntimeIfLazy: async () => undefined,
35
+ }));
36
+
37
+ const C1 = cellId("c1");
38
+ const C2 = cellId("c2");
39
+
40
+ function accepted(cells: Protocol.CellId[]): Protocol.Run {
41
+ return {
42
+ id: "run-1",
43
+ notebook_id: "n1",
44
+ runtime_id: "r1",
45
+ cells,
46
+ status: "queued",
47
+ outcome: null,
48
+ started_at: null,
49
+ completed_at: null,
50
+ };
51
+ }
52
+
53
+ function editable(disabled: Protocol.CellId[] = []): EditableDocument {
54
+ const confirmed = documentOf();
55
+ return {
56
+ order: confirmed.revision.order,
57
+ cells: new Map(
58
+ [...confirmed.cells.values()].map((cell) => [
59
+ cell.id,
60
+ {
61
+ ...cell,
62
+ config: { ...cell.config, disabled: disabled.includes(cell.id) },
63
+ },
64
+ ]),
65
+ ),
66
+ };
67
+ }
68
+
69
+ /** A model whose edits sync with `sync`, confirming the fixture document by default. */
70
+ function model(
71
+ sync: () => Promise<Protocol.Document> = async () => documentOf().revision,
72
+ document: EditableDocument | null = editable(),
73
+ ): DocumentModel {
74
+ const model = {
75
+ retry: vi.fn(sync),
76
+ documentAtom: atom(document),
77
+ } as unknown as DocumentModel;
78
+ store.set(activeDocumentModelAtom, model);
79
+ return model;
80
+ }
81
+
82
+ function status(id: Protocol.CellId) {
83
+ return cellExecutionView(store.get(cellExecutionInputsAtom), id).status;
84
+ }
85
+
86
+ describe("runCells", () => {
87
+ let run: ReturnType<typeof vi.fn<FakeRun>>;
88
+
89
+ beforeEach(() => {
90
+ toast.mockClear();
91
+ run = vi.fn<FakeRun>(async (_notebookId, request) => ({
92
+ ok: true,
93
+ status: 202,
94
+ data: accepted(request.cells?.map((cell) => cell.id) ?? []),
95
+ }));
96
+ const replica = MockExecutions.seed(
97
+ store,
98
+ [],
99
+ MockExecutions.runtime("running"),
100
+ documentOf(),
101
+ );
102
+ store.set(replica.backendAtom, fakeBackend({ run }));
103
+ });
104
+
105
+ afterEach(() => {
106
+ store.set(activeDocumentModelAtom, null);
107
+ });
108
+
109
+ it("queues the cells at once, then runs them by id after the edits sync", async () => {
110
+ const { retry } = model();
111
+
112
+ const pending = runCells(store, [C1, C2]);
113
+ expect(status(C1)).toBe("queued");
114
+ expect(status(C2)).toBe("queued");
115
+ await pending;
116
+
117
+ // Retrying covers a synchronization that stalled before the run.
118
+ expect(retry).toHaveBeenCalledOnce();
119
+ expect(run).toHaveBeenCalledWith("n1", {
120
+ cells: [{ id: C1 }, { id: C2 }],
121
+ });
122
+ expect(vi.mocked(retry).mock.invocationCallOrder[0]).toBeLessThan(
123
+ run.mock.invocationCallOrder[0],
124
+ );
125
+ // Queued until the host plans the run or ends it.
126
+ expect(status(C1)).toBe("queued");
127
+ expect(toast).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it("sends a disabled cell without reading it as queued", async () => {
131
+ const replica = MockExecutions.seed(
132
+ store,
133
+ [],
134
+ MockExecutions.runtime("running"),
135
+ withCells(documentOf(), {
136
+ c2: cell("c2", "y = 2", { config: { ...CONFIG, disabled: true } }),
137
+ }),
138
+ );
139
+ store.set(replica.backendAtom, fakeBackend({ run }));
140
+ model(undefined, editable([C2]));
141
+
142
+ await runCells(store, [C1, C2]);
143
+
144
+ expect(run).toHaveBeenCalledWith("n1", {
145
+ cells: [{ id: C1 }, { id: C2 }],
146
+ });
147
+ expect(status(C1)).toBe("queued");
148
+ expect(status(C2)).toBe("idle");
149
+ });
150
+
151
+ it("waits for the runtime to run before submitting", async () => {
152
+ const replica = MockExecutions.seed(
153
+ store,
154
+ [],
155
+ MockExecutions.runtime("starting"),
156
+ documentOf(),
157
+ );
158
+ store.set(replica.backendAtom, fakeBackend({ run }));
159
+ model();
160
+
161
+ const pending = runCells(store, [C1]);
162
+ await Promise.resolve();
163
+ expect(status(C1)).toBe("queued");
164
+ expect(run).not.toHaveBeenCalled();
165
+
166
+ store.set(replica.runtimeAtom, MockExecutions.runtime("running"));
167
+ await pending;
168
+ expect(run).toHaveBeenCalledOnce();
169
+ });
170
+
171
+ it("records the request once the stream has a notebook, as after a reconnect", async () => {
172
+ const replica = MockExecutions.seed(store, [], null, documentOf());
173
+ store.set(replica.backendAtom, fakeBackend({ run }));
174
+ store.set(replica.notebookAtom, null);
175
+ model();
176
+
177
+ const pending = runCells(store, [C1]);
178
+ await Promise.resolve();
179
+ expect(run).not.toHaveBeenCalled();
180
+
181
+ // The real replica reports both at once, from one snapshot.
182
+ store.set(replica.runtimeAtom, MockExecutions.runtime("running"));
183
+ store.set(replica.notebookAtom, MockExecutions.notebook());
184
+ await pending;
185
+
186
+ expect(run).toHaveBeenCalledWith("n1", { cells: [{ id: C1 }] });
187
+ expect(status(C1)).toBe("queued");
188
+ expect(toast).not.toHaveBeenCalled();
189
+ });
190
+
191
+ it("withdraws the submission and reports when the edits cannot sync", async () => {
192
+ model(async () => {
193
+ throw new Error("offline");
194
+ });
195
+
196
+ await runCells(store, [C1]);
197
+
198
+ expect(run).not.toHaveBeenCalled();
199
+ expect(status(C1)).toBe("idle");
200
+ expect(toast).toHaveBeenCalledWith(
201
+ expect.objectContaining({ title: "Failed to run", variant: "danger" }),
202
+ );
203
+ });
204
+
205
+ it("withdraws the submission and reports when the runtime has failed", async () => {
206
+ const replica = MockExecutions.seed(
207
+ store,
208
+ [],
209
+ { ...MockExecutions.runtime("failed"), message: "The kernel exited" },
210
+ documentOf(),
211
+ );
212
+ store.set(replica.backendAtom, fakeBackend({ run }));
213
+ model();
214
+
215
+ await runCells(store, [C1]);
216
+
217
+ expect(run).not.toHaveBeenCalled();
218
+ expect(status(C1)).toBe("idle");
219
+ expect(toast).toHaveBeenCalledWith(
220
+ expect.objectContaining({ description: "The kernel exited" }),
221
+ );
222
+ });
223
+
224
+ it("withdraws the submission and reports when the notebook has no runtime", async () => {
225
+ const replica = MockExecutions.seed(store, [], null, documentOf());
226
+ store.set(replica.backendAtom, fakeBackend({ run }));
227
+ model();
228
+
229
+ await runCells(store, [C1]);
230
+
231
+ expect(run).not.toHaveBeenCalled();
232
+ expect(status(C1)).toBe("idle");
233
+ expect(toast).toHaveBeenCalledWith(
234
+ expect.objectContaining({ description: "The notebook has no runtime" }),
235
+ );
236
+ });
237
+
238
+ it("stops waiting for the runtime once the connection closes", async () => {
239
+ const replica = MockExecutions.seed(
240
+ store,
241
+ [],
242
+ MockExecutions.runtime("starting"),
243
+ documentOf(),
244
+ );
245
+ store.set(replica.backendAtom, fakeBackend({ run }));
246
+ model();
247
+
248
+ const pending = runCells(store, [C1]);
249
+ await Promise.resolve();
250
+ expect(status(C1)).toBe("queued");
251
+
252
+ store.set(replica.connectionAtom, {
253
+ state: ConnectionState.CLOSED,
254
+ code: ConnectionClosedReason.KERNEL_DISCONNECTED,
255
+ reason: "kernel not found",
256
+ });
257
+ await pending;
258
+
259
+ expect(run).not.toHaveBeenCalled();
260
+ expect(status(C1)).toBe("idle");
261
+ expect(toast).toHaveBeenCalledWith(
262
+ expect.objectContaining({ description: "The notebook is disconnected" }),
263
+ );
264
+ });
265
+
266
+ it("withdraws the submission and reports when the host refuses the run", async () => {
267
+ run.mockResolvedValue({
268
+ ok: false,
269
+ status: 409,
270
+ problem: {
271
+ type: "tag:marimo.io,2026:host/conflict",
272
+ title: "Conflict",
273
+ status: 409,
274
+ detail: "the runtime is starting.",
275
+ },
276
+ });
277
+ model();
278
+
279
+ await runCells(store, [C1]);
280
+
281
+ expect(status(C1)).toBe("idle");
282
+ expect(toast).toHaveBeenCalledWith(
283
+ expect.objectContaining({ description: "the runtime is starting." }),
284
+ );
285
+ });
286
+
287
+ it("withdraws quietly when the connection is read-only", async () => {
288
+ run.mockResolvedValue({
289
+ ok: false,
290
+ status: 403,
291
+ problem: {
292
+ type: "tag:marimo.io,2026:host/forbidden",
293
+ title: "Forbidden",
294
+ status: 403,
295
+ },
296
+ });
297
+ model();
298
+
299
+ await runCells(store, [C1]);
300
+
301
+ expect(status(C1)).toBe("idle");
302
+ expect(toast).not.toHaveBeenCalled();
303
+ });
304
+
305
+ it("does not run when the notebook closed while the edits synced", async () => {
306
+ model(async () => {
307
+ store.set(activeDocumentModelAtom, null);
308
+ return documentOf().revision;
309
+ });
310
+
311
+ await runCells(store, [C1]);
312
+
313
+ expect(run).not.toHaveBeenCalled();
314
+ expect(toast).not.toHaveBeenCalled();
315
+ });
316
+
317
+ it("does not run in a notebook that replaced the one the request was made in", async () => {
318
+ // Islands keep the replica and model while the worker's session changes.
319
+ const replica = MockExecutions.seed(
320
+ store,
321
+ [],
322
+ MockExecutions.runtime("running"),
323
+ documentOf(),
324
+ );
325
+ store.set(replica.backendAtom, fakeBackend({ run }));
326
+ model(async () => {
327
+ store.set(replica.notebookAtom, MockExecutions.notebook("n2"));
328
+ return documentOf("n2").revision;
329
+ });
330
+
331
+ const pending = runCells(store, [C1]);
332
+ expect(status(C1)).toBe("queued");
333
+ await pending;
334
+
335
+ expect(run).not.toHaveBeenCalled();
336
+ expect(status(C1)).toBe("idle");
337
+ expect(toast).not.toHaveBeenCalled();
338
+ });
339
+
340
+ it("leaves the replacing notebook's request when a late refusal lands", async () => {
341
+ const replica = MockExecutions.seed(
342
+ store,
343
+ [],
344
+ MockExecutions.runtime("running"),
345
+ documentOf(),
346
+ );
347
+ store.set(replica.backendAtom, fakeBackend({ run }));
348
+ model(
349
+ async () =>
350
+ documentOf(store.get(replica.notebookAtom)?.id ?? "n1").revision,
351
+ );
352
+ let refuse = (): void => undefined;
353
+ run.mockImplementationOnce(
354
+ () =>
355
+ new Promise((resolve) => {
356
+ refuse = () =>
357
+ resolve({
358
+ ok: false,
359
+ status: 409,
360
+ problem: {
361
+ type: "tag:marimo.io,2026:host/conflict",
362
+ title: "Conflict",
363
+ status: 409,
364
+ },
365
+ });
366
+ }),
367
+ );
368
+
369
+ const first = runCells(store, [C1]);
370
+ await vi.waitFor(() => expect(run).toHaveBeenCalledOnce());
371
+ store.set(replica.notebookAtom, MockExecutions.notebook("n2"));
372
+ const second = runCells(store, [C1]);
373
+ await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2));
374
+ expect(status(C1)).toBe("queued");
375
+
376
+ refuse();
377
+ await first;
378
+ await second;
379
+
380
+ expect(run).toHaveBeenLastCalledWith("n2", { cells: [{ id: C1 }] });
381
+ expect(status(C1)).toBe("queued");
382
+ });
383
+
384
+ it("does nothing without an open notebook", async () => {
385
+ await runCells(store, [C1]);
386
+
387
+ expect(run).not.toHaveBeenCalled();
388
+ expect(status(C1)).toBe("idle");
389
+ });
390
+ });
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type * as Protocol from "@marimo-team/client/protocol";
4
4
  import { describe, expect, it } from "vitest";
5
+ import { CONFIG } from "@/core/notebook/document/__tests__/fixtures";
5
6
  import type { Execution, Executions, Results } from "@/core/notebook/replica";
6
7
  import {
7
8
  anyRunning,
@@ -39,19 +40,43 @@ function execution(overrides: Partial<Protocol.Execution> & { id: string }) {
39
40
  return { record: record(overrides), items: new Map(), variables: new Map() };
40
41
  }
41
42
 
43
+ function run(
44
+ cells: string[],
45
+ status: Protocol.Run["status"],
46
+ id = "run-1",
47
+ ): Protocol.Run {
48
+ return {
49
+ id,
50
+ notebook_id: "n1",
51
+ runtime_id: "r1",
52
+ cells,
53
+ status,
54
+ outcome: status === "completed" ? "succeeded" : null,
55
+ started_at: null,
56
+ completed_at: null,
57
+ };
58
+ }
59
+
60
+ const CELLS = ["c1", "c2", "c3", "c4", "c5"];
61
+
62
+ /** Inputs whose document has `CELLS`, with `disabled` kept from running. */
42
63
  function inputs({
43
64
  executions = [],
65
+ runs = [],
44
66
  results = new Map(),
45
- submissions = new Map(),
67
+ requested = new Set(),
68
+ disabled = [],
46
69
  blocked = new Set(),
47
70
  }: {
48
71
  executions?: Execution[];
72
+ runs?: Protocol.Run[];
49
73
  results?: Results;
50
- submissions?: ReadonlyMap<string, string | null>;
74
+ requested?: ReadonlySet<string>;
75
+ disabled?: readonly string[];
51
76
  blocked?: ReadonlySet<string>;
52
77
  }): CellExecutionInputs {
53
78
  const stream: Executions = {
54
- runs: new Map(),
79
+ runs: new Map(runs.map((r) => [r.id, r])),
55
80
  executions: new Map(executions.map((e) => [e.record.id, e])),
56
81
  latest: new Map(
57
82
  executions.flatMap((e) =>
@@ -59,7 +84,13 @@ function inputs({
59
84
  ),
60
85
  ),
61
86
  };
62
- return { executions: stream, results, submissions, blocked };
87
+ const cells = new Map(
88
+ CELLS.map((id) => [
89
+ id,
90
+ { config: { ...CONFIG, disabled: disabled.includes(id) } },
91
+ ]),
92
+ );
93
+ return { executions: stream, results, cells, requested, blocked };
63
94
  }
64
95
 
65
96
  describe("cellExecutionView", () => {
@@ -165,40 +196,87 @@ describe("cellExecutionView", () => {
165
196
  expect(ended({ kind: "interrupted" }).outcome).toBe("interrupted");
166
197
  });
167
198
 
168
- it("reads as queued from a submission until the host shows a newer execution", () => {
169
- const before = inputs({
199
+ it("reads as queued from a request the stream has not shown a run for", () => {
200
+ const requested = inputs({
170
201
  executions: [execution({ id: "e1" })],
171
202
  results: new Map([["c1", record({ id: "e1" })]]),
172
- submissions: new Map([["c1", "e1"]]),
203
+ requested: new Set(["c1"]),
173
204
  });
174
- expect(cellExecutionView(before, "c1").status).toBe("queued");
175
- expect(cellExecutionView(before, "c1").outcome).toBeNull();
205
+ expect(cellExecutionView(requested, "c1").status).toBe("queued");
206
+ expect(cellExecutionView(requested, "c1").outcome).toBeNull();
176
207
 
177
- // The stream let e1 go while the request was in flight; its result remains.
178
- const forgotten = inputs({
179
- results: new Map([["c1", record({ id: "e1" })]]),
180
- submissions: new Map([["c1", "e1"]]),
208
+ const fresh = inputs({ requested: new Set(["c1"]) });
209
+ expect(cellExecutionView(fresh, "c1").status).toBe("queued");
210
+ });
211
+
212
+ it("reads as queued from an unfinished run that names the cell until it is planned", () => {
213
+ const result = new Map([["c1", record({ id: "e1" })]]);
214
+ const accepted = inputs({
215
+ runs: [run(["c1"], "queued", "run-2")],
216
+ executions: [execution({ id: "e1" })],
217
+ results: result,
181
218
  });
182
- expect(cellExecutionView(forgotten, "c1").status).toBe("queued");
219
+ expect(cellExecutionView(accepted, "c1").status).toBe("queued");
220
+ expect(cellExecutionView(accepted, "c1").outcome).toBeNull();
183
221
 
184
- const shown = inputs({
222
+ // The cell's execution in the run takes over once the kernel plans it.
223
+ const planned = inputs({
224
+ runs: [run(["c1"], "running", "run-2")],
185
225
  executions: [
186
- execution({ id: "e2", status: "running", completed_at: null }),
226
+ execution({
227
+ id: "e2",
228
+ run_id: "run-2",
229
+ status: "running",
230
+ completed_at: null,
231
+ }),
187
232
  ],
188
- results: new Map([["c1", record({ id: "e1" })]]),
189
- submissions: new Map([["c1", "e1"]]),
233
+ results: result,
190
234
  });
191
- expect(cellExecutionView(shown, "c1").status).toBe("running");
235
+ expect(cellExecutionView(planned, "c1").status).toBe("running");
192
236
 
193
237
  const done = inputs({
194
- executions: [execution({ id: "e2" })],
195
- results: new Map([["c1", record({ id: "e2" })]]),
196
- submissions: new Map([["c1", "e1"]]),
238
+ runs: [run(["c1", "c2"], "running", "run-2")],
239
+ executions: [execution({ id: "e2", run_id: "run-2" })],
240
+ results: new Map([["c1", record({ id: "e2", run_id: "run-2" })]]),
197
241
  });
198
242
  expect(cellExecutionView(done, "c1").status).toBe("idle");
243
+ expect(cellExecutionView(done, "c1").outcome).toBe("value");
244
+ expect(cellExecutionView(done, "c2").status).toBe("queued");
199
245
 
200
- const fresh = inputs({ submissions: new Map([["c1", null]]) });
201
- expect(cellExecutionView(fresh, "c1").status).toBe("queued");
246
+ // The stream let the execution go while its run goes on with other cells.
247
+ const retained = inputs({
248
+ runs: [run(["c1", "c2"], "running", "run-2")],
249
+ results: new Map([["c1", record({ id: "e2", run_id: "run-2" })]]),
250
+ });
251
+ expect(cellExecutionView(retained, "c1").status).toBe("idle");
252
+ expect(cellExecutionView(retained, "c2").status).toBe("queued");
253
+
254
+ // A later run names the cell again while the earlier one's result stands.
255
+ const again = inputs({
256
+ runs: [run(["c1"], "running", "run-2"), run(["c1"], "queued", "run-3")],
257
+ executions: [execution({ id: "e2", run_id: "run-2" })],
258
+ results: new Map([["c1", record({ id: "e2", run_id: "run-2" })]]),
259
+ });
260
+ expect(cellExecutionView(again, "c1").status).toBe("queued");
261
+
262
+ const over = inputs({ runs: [run(["c1"], "completed")] });
263
+ expect(cellExecutionView(over, "c1").status).toBe("idle");
264
+ });
265
+
266
+ it("does not read as queued a named cell a run cannot reach", () => {
267
+ const runs = [run(["c1", "c9"], "queued")];
268
+ expect(
269
+ cellExecutionView(inputs({ runs, disabled: ["c1"] }), "c1").status,
270
+ ).toBe("idle");
271
+ expect(
272
+ cellExecutionView(inputs({ runs, blocked: new Set(["c1"]) }), "c1")
273
+ .status,
274
+ ).toBe("idle");
275
+ // The document no longer has c9.
276
+ expect(cellExecutionView(inputs({ runs }), "c9").status).toBe("idle");
277
+ expect(
278
+ cellExecutionView(inputs({ requested: new Set(["c9"]) }), "c9").status,
279
+ ).toBe("idle");
202
280
  });
203
281
 
204
282
  it("shows a blocked cell as disabled transitively, with its last outcome", () => {
@@ -234,15 +312,12 @@ describe("notebook-level views", () => {
234
312
  completed_at: null,
235
313
  }),
236
314
  ],
315
+ runs: [run(["c1", "c2", "c5"], "running"), run(["c5"], "queued", "run-2")],
237
316
  results: new Map([["c5", record({ id: "e5", cell_id: "c5" })]]),
238
- submissions: new Map([
239
- ["c2", "e2"],
240
- ["c4", null],
241
- ["c5", "e5"],
242
- ]),
317
+ requested: new Set(["c2", "c4"]),
243
318
  });
244
319
 
245
- it("counts unfinished executions and submissions the host has not shown", () => {
320
+ it("counts unfinished executions and cells awaiting a run the kernel has not planned", () => {
246
321
  expect(anyRunning(stream.executions)).toBe(true);
247
322
  expect(queuedOrRunningCount(stream)).toBe(5);
248
323
  expect(runningCellIds(stream.executions)).toEqual(["c1"]);