@marimo-team/islands 0.23.15-dev33 → 0.23.15-dev4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ConnectedDataExplorerComponent-7p7rt9iw.js → ConnectedDataExplorerComponent-CU4fZJzG.js} +4 -4
- package/dist/{ErrorBoundary-B_CAG7_e.js → ErrorBoundary-DE6tzZf-.js} +1 -1
- package/dist/{ImageComparisonComponent-1i5IBhon.js → ImageComparisonComponent-CHrI72em.js} +129 -173
- package/dist/assets/__vite-browser-external-BQCLNwri.js +1 -0
- package/dist/assets/worker-DAWRHcPq.js +73 -0
- package/dist/{chat-ui-C0wkSwxB.js → chat-ui-DinOvbWu.js} +3111 -3111
- package/dist/{code-visibility-BI94mK4u.js → code-visibility-Dk8cVOny.js} +864 -946
- package/dist/{debounce-BOD3DbfP.js → constants-T20xxyNf.js} +24 -1
- package/dist/{formats-BX3uDQbB.js → formats-Dzx4J_z1.js} +1 -1
- package/dist/{glide-data-editor-CwZz71BD.js → glide-data-editor-CjTu7ukN.js} +3 -3
- package/dist/{html-to-image-D8Q5SLSJ.js → html-to-image-_wGfk8V-.js} +2241 -2277
- package/dist/{input-BGPrFH3g.js → input-DtsN7xm-.js} +2 -2
- package/dist/main.js +1201 -1297
- package/dist/{mermaid-UdmxG2PZ.js → mermaid-BYqXy_NE.js} +2 -2
- package/dist/{process-output-C_A12w9J.js → process-output-Mh4UrjwM.js} +1 -1
- package/dist/{reveal-component-ELidGmPF.js → reveal-component-B5DXLyO7.js} +605 -618
- package/dist/{spec-DSs9v0xx.js → spec-Cz-Bj1JI.js} +1 -1
- package/dist/style.css +1 -1
- package/dist/{toDate-CKpRx4TS.js → toDate-CWNNlFEX.js} +4 -3
- package/dist/{useAsyncData-BSOyAbac.js → useAsyncData-KfHB8wQR.js} +1 -1
- package/dist/{useDeepCompareMemoize-BTLeWzuR.js → useDeepCompareMemoize-nJMtxhm4.js} +1 -1
- package/dist/{useLifecycle-C6wHjkhW.js → useLifecycle-DegSo0lV.js} +1 -1
- package/dist/{useTheme-Df_vGflw.js → useTheme-6eZ3GOTS.js} +27 -62
- package/dist/{vega-component-DNHEV0u0.js → vega-component-DzyyM9fc.js} +6 -6
- package/package.json +1 -1
- package/src/components/chat/__tests__/chat-utils.test.ts +1 -244
- package/src/components/chat/chat-panel.tsx +67 -196
- package/src/components/chat/chat-utils.ts +2 -111
- package/src/components/editor/SortableCell.tsx +2 -6
- package/src/components/editor/actions/pair-with-agent-modal.tsx +64 -20
- package/src/components/editor/ai/add-cell-with-ai.tsx +5 -14
- package/src/components/editor/ai/ai-completion-editor.tsx +1 -4
- package/src/components/editor/cell/cell-context-menu.tsx +0 -7
- package/src/components/editor/chrome/panels/packages-panel.tsx +1 -11
- package/src/components/editor/columns/cell-column.tsx +4 -34
- package/src/components/editor/columns/sortable-column.tsx +4 -24
- package/src/components/editor/navigation/__tests__/navigation.test.ts +0 -33
- package/src/components/editor/navigation/navigation.ts +1 -8
- package/src/components/editor/notebook-cell.tsx +106 -203
- package/src/components/editor/renderers/cell-array.tsx +13 -26
- package/src/components/editor/renderers/cells-renderer.tsx +2 -8
- package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +0 -29
- package/src/components/editor/renderers/slides-layout/types.ts +0 -4
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +19 -19
- package/src/components/slides/reveal-component.tsx +4 -37
- package/src/components/slides/slide-form.tsx +0 -72
- package/src/core/cells/__tests__/utils.test.ts +0 -59
- package/src/core/cells/utils.ts +0 -51
- package/src/core/codemirror/completion/__tests__/signature-hint.test.ts +3 -97
- package/src/core/codemirror/completion/signature-hint.ts +11 -78
- package/src/core/constants.ts +0 -6
- package/src/core/islands/__tests__/bridge.test.ts +46 -341
- package/src/core/islands/__tests__/parse.test.ts +0 -105
- package/src/core/islands/bootstrap.ts +3 -8
- package/src/core/islands/bridge.ts +23 -116
- package/src/core/islands/components/web-components.tsx +15 -76
- package/src/core/islands/constants.ts +0 -1
- package/src/core/islands/main.ts +3 -69
- package/src/core/islands/parse.ts +23 -70
- package/src/core/islands/worker/worker.tsx +57 -145
- package/src/core/wasm/worker/bootstrap.ts +20 -113
- package/src/css/app/Cell.css +0 -10
- package/src/hooks/useHotkey.ts +4 -29
- package/src/plugins/impl/anywidget/__tests__/host.test.ts +9 -6
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +61 -22
- package/src/plugins/impl/anywidget/model-proxy.ts +13 -0
- package/src/plugins/impl/anywidget/widget-binding.ts +34 -4
- package/src/plugins/impl/image-comparison/ImageComparisonComponent.tsx +2 -53
- package/src/plugins/impl/matplotlib/__tests__/matplotlib-renderer.test.ts +3 -71
- package/src/plugins/impl/matplotlib/matplotlib-renderer.ts +0 -1
- package/src/theme/useTheme.ts +1 -16
- package/dist/assets/__vite-browser-external-CjNAy01L.js +0 -1
- package/dist/assets/worker-B3XPCb6y.js +0 -98
- package/src/components/chat/__tests__/message-queue.test.tsx +0 -121
- package/src/components/editor/__tests__/output-persistence.test.tsx +0 -241
- package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +0 -153
- package/src/components/editor/actions/pair-with-agent-commands.ts +0 -109
- package/src/components/editor/columns/__tests__/cell-column.test.tsx +0 -105
- package/src/components/editor/renderers/__tests__/cells-renderer.test.tsx +0 -66
- package/src/core/islands/__tests__/main.test.ts +0 -176
- package/src/core/islands/components/__tests__/web-components.test.tsx +0 -214
- package/src/core/islands/worker/__tests__/controller.test.ts +0 -173
- package/src/hooks/__tests__/useHotkey.test.tsx +0 -88
- package/src/plugins/impl/image-comparison/__tests__/ImageComparisonComponent.test.tsx +0 -71
- package/src/theme/__tests__/useTheme.test.ts +0 -68
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
|
|
3
|
-
import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
|
|
4
|
-
import type * as MainModule from "../main";
|
|
5
|
-
|
|
6
|
-
// `main.ts` is the public islands entry point (see islands/vite.config.mts),
|
|
7
|
-
// so this suite snapshots its export surface + signatures and covers each
|
|
8
|
-
// entry point. The real bridge/bootstrap spin up a Web Worker and the plugin
|
|
9
|
-
// runtime, so we mock them.
|
|
10
|
-
const mockInitializeApps = vi.fn(() => Promise.resolve());
|
|
11
|
-
const mockStopSession = vi.fn((_appId?: string) => Promise.resolve());
|
|
12
|
-
const mockInitializeIslands = vi.fn(() => Promise.resolve());
|
|
13
|
-
const mockParseMarimoIslandApps = vi.fn(() => [] as Array<{ id: string }>);
|
|
14
|
-
|
|
15
|
-
vi.mock("@/core/islands/bridge", () => ({
|
|
16
|
-
getGlobalBridge: () => ({
|
|
17
|
-
initializeApps: mockInitializeApps,
|
|
18
|
-
stopSession: mockStopSession,
|
|
19
|
-
}),
|
|
20
|
-
}));
|
|
21
|
-
|
|
22
|
-
vi.mock("@/core/islands/bootstrap", () => ({
|
|
23
|
-
initializeIslands: mockInitializeIslands,
|
|
24
|
-
}));
|
|
25
|
-
|
|
26
|
-
vi.mock("@/core/islands/parse", () => ({
|
|
27
|
-
parseMarimoIslandApps: mockParseMarimoIslandApps,
|
|
28
|
-
}));
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Re-import a fresh copy of `main.ts` so the module-level `bootstrapPromise`
|
|
32
|
-
* memoization does not leak between tests. The module auto-runs `initialize()`
|
|
33
|
-
* on load, so we clear the DOM first to keep that a no-op.
|
|
34
|
-
*/
|
|
35
|
-
async function importMain(): Promise<typeof MainModule> {
|
|
36
|
-
vi.resetModules();
|
|
37
|
-
return import("../main");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
beforeEach(() => {
|
|
41
|
-
document.body.replaceChildren();
|
|
42
|
-
mockInitializeApps.mockClear();
|
|
43
|
-
mockStopSession.mockClear();
|
|
44
|
-
mockInitializeIslands.mockClear();
|
|
45
|
-
mockParseMarimoIslandApps.mockReset();
|
|
46
|
-
mockParseMarimoIslandApps.mockReturnValue([]);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
describe("islands public API surface", () => {
|
|
50
|
-
it("exposes a stable set of exports and signatures", async () => {
|
|
51
|
-
const mod = await importMain();
|
|
52
|
-
const surface = Object.fromEntries(
|
|
53
|
-
Object.keys(mod)
|
|
54
|
-
.toSorted()
|
|
55
|
-
.map((key) => {
|
|
56
|
-
const value = (mod as Record<string, unknown>)[key];
|
|
57
|
-
return [
|
|
58
|
-
key,
|
|
59
|
-
typeof value === "function"
|
|
60
|
-
? `function(arity ${value.length})`
|
|
61
|
-
: typeof value,
|
|
62
|
-
];
|
|
63
|
-
}),
|
|
64
|
-
);
|
|
65
|
-
|
|
66
|
-
expect(surface).toMatchInlineSnapshot(`
|
|
67
|
-
{
|
|
68
|
-
"canReplaceApp": "function(arity 0)",
|
|
69
|
-
"initialize": "function(arity 0)",
|
|
70
|
-
"stopApp": "function(arity 1)",
|
|
71
|
-
}
|
|
72
|
-
`);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("matches the declared TypeScript signatures", () => {
|
|
76
|
-
expectTypeOf<typeof MainModule.canReplaceApp>().toEqualTypeOf<
|
|
77
|
-
() => boolean
|
|
78
|
-
>();
|
|
79
|
-
expectTypeOf<typeof MainModule.initialize>().toEqualTypeOf<
|
|
80
|
-
() => Promise<void>
|
|
81
|
-
>();
|
|
82
|
-
expectTypeOf<typeof MainModule.stopApp>().toEqualTypeOf<
|
|
83
|
-
(appId?: string) => Promise<void>
|
|
84
|
-
>();
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
describe("canReplaceApp", () => {
|
|
89
|
-
it("returns false when the document has no island element", async () => {
|
|
90
|
-
const mod = await importMain();
|
|
91
|
-
|
|
92
|
-
expect(mod.canReplaceApp()).toBe(false);
|
|
93
|
-
// Short-circuits on the DOM guard before parsing.
|
|
94
|
-
expect(mockParseMarimoIslandApps).not.toHaveBeenCalled();
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("returns true for exactly one app without materializing the DOM", async () => {
|
|
98
|
-
const mod = await importMain();
|
|
99
|
-
document.body.innerHTML = `<marimo-island data-app-id="app-1"></marimo-island>`;
|
|
100
|
-
mockParseMarimoIslandApps.mockReturnValue([{ id: "app-1" }]);
|
|
101
|
-
|
|
102
|
-
expect(mod.canReplaceApp()).toBe(true);
|
|
103
|
-
// A probe must not mutate island attributes.
|
|
104
|
-
expect(mockParseMarimoIslandApps).toHaveBeenCalledWith(document, {
|
|
105
|
-
materialize: false,
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("returns false when more than one app is present", async () => {
|
|
110
|
-
const mod = await importMain();
|
|
111
|
-
document.body.innerHTML = `<marimo-island data-app-id="app-1"></marimo-island>`;
|
|
112
|
-
mockParseMarimoIslandApps.mockReturnValue([
|
|
113
|
-
{ id: "app-1" },
|
|
114
|
-
{ id: "app-2" },
|
|
115
|
-
]);
|
|
116
|
-
|
|
117
|
-
expect(mod.canReplaceApp()).toBe(false);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("returns false when the island parses into zero apps", async () => {
|
|
121
|
-
const mod = await importMain();
|
|
122
|
-
document.body.innerHTML = `<marimo-island></marimo-island>`;
|
|
123
|
-
mockParseMarimoIslandApps.mockReturnValue([]);
|
|
124
|
-
|
|
125
|
-
expect(mod.canReplaceApp()).toBe(false);
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
describe("stopApp", () => {
|
|
130
|
-
it("forwards the requested app id to the bridge", async () => {
|
|
131
|
-
const mod = await importMain();
|
|
132
|
-
|
|
133
|
-
await mod.stopApp("app-7");
|
|
134
|
-
expect(mockStopSession).toHaveBeenCalledWith("app-7");
|
|
135
|
-
|
|
136
|
-
await mod.stopApp();
|
|
137
|
-
expect(mockStopSession).toHaveBeenLastCalledWith(undefined);
|
|
138
|
-
});
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
describe("initialize", () => {
|
|
142
|
-
it("skips bootstrap and app start when no islands are present", async () => {
|
|
143
|
-
const mod = await importMain();
|
|
144
|
-
|
|
145
|
-
await mod.initialize();
|
|
146
|
-
|
|
147
|
-
expect(mockInitializeIslands).not.toHaveBeenCalled();
|
|
148
|
-
expect(mockInitializeApps).not.toHaveBeenCalled();
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("bootstraps once, marks islands, and starts apps on each call", async () => {
|
|
152
|
-
const mod = await importMain();
|
|
153
|
-
document.body.innerHTML = `<marimo-island></marimo-island>`;
|
|
154
|
-
|
|
155
|
-
await mod.initialize();
|
|
156
|
-
await mod.initialize();
|
|
157
|
-
|
|
158
|
-
expect(mockInitializeIslands).toHaveBeenCalledOnce();
|
|
159
|
-
expect(mockInitializeApps).toHaveBeenCalledTimes(2);
|
|
160
|
-
expect(
|
|
161
|
-
document.querySelector("marimo-island")?.classList.contains("marimo"),
|
|
162
|
-
).toBe(true);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it("clears the memoized bootstrap when it fails so it can be retried", async () => {
|
|
166
|
-
const mod = await importMain();
|
|
167
|
-
document.body.innerHTML = `<marimo-island></marimo-island>`;
|
|
168
|
-
mockInitializeIslands.mockRejectedValueOnce(new Error("bootstrap failed"));
|
|
169
|
-
|
|
170
|
-
await expect(mod.initialize()).rejects.toThrow("bootstrap failed");
|
|
171
|
-
await mod.initialize();
|
|
172
|
-
|
|
173
|
-
expect(mockInitializeIslands).toHaveBeenCalledTimes(2);
|
|
174
|
-
expect(mockInitializeApps).toHaveBeenCalledOnce();
|
|
175
|
-
});
|
|
176
|
-
});
|
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
|
|
3
|
-
import { act } from "react";
|
|
4
|
-
import {
|
|
5
|
-
afterAll,
|
|
6
|
-
afterEach,
|
|
7
|
-
beforeAll,
|
|
8
|
-
beforeEach,
|
|
9
|
-
describe,
|
|
10
|
-
expect,
|
|
11
|
-
it,
|
|
12
|
-
} from "vitest";
|
|
13
|
-
import { cellId } from "@/__tests__/branded";
|
|
14
|
-
import { MockNotebook } from "@/__mocks__/notebook";
|
|
15
|
-
import { MockRequestClient } from "@/__mocks__/requests";
|
|
16
|
-
import { notebookAtom } from "@/core/cells/cells";
|
|
17
|
-
import {
|
|
18
|
-
ISLAND_DATA_ATTRIBUTES,
|
|
19
|
-
ISLAND_TAG_NAMES,
|
|
20
|
-
ISLANDS_JSON_SCRIPT_TYPE,
|
|
21
|
-
} from "@/core/islands/constants";
|
|
22
|
-
import { requestClientAtom } from "@/core/network/requests";
|
|
23
|
-
import { store } from "@/core/state/jotai";
|
|
24
|
-
import { parseMarimoIslandApps } from "../../parse";
|
|
25
|
-
import { MarimoIslandElement } from "../web-components";
|
|
26
|
-
|
|
27
|
-
beforeAll(() => {
|
|
28
|
-
setReactActEnvironment(true);
|
|
29
|
-
if (!customElements.get(ISLAND_TAG_NAMES.ISLAND)) {
|
|
30
|
-
customElements.define(ISLAND_TAG_NAMES.ISLAND, MarimoIslandElement);
|
|
31
|
-
}
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
afterAll(() => {
|
|
35
|
-
setReactActEnvironment(false);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
beforeEach(() => {
|
|
39
|
-
store.set(notebookAtom, MockNotebook.notebookState({ cellData: {} }));
|
|
40
|
-
store.set(requestClientAtom, MockRequestClient.create());
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
afterEach(async () => {
|
|
44
|
-
await actAndFlush(() => document.body.replaceChildren());
|
|
45
|
-
store.set(requestClientAtom, null);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
describe("MarimoIslandElement lifecycle", () => {
|
|
49
|
-
it("binds a reactive island after its runtime cell index is materialized", async () => {
|
|
50
|
-
setNotebook("outgoing-cell", "outgoing runtime output");
|
|
51
|
-
const container = document.createElement("div");
|
|
52
|
-
container.innerHTML = `
|
|
53
|
-
<marimo-island data-app-id="app-1" data-reactive="true">
|
|
54
|
-
<marimo-cell-output><div>initial output</div></marimo-cell-output>
|
|
55
|
-
<marimo-cell-code hidden>${encodeURIComponent("value = 1")}</marimo-cell-code>
|
|
56
|
-
</marimo-island>
|
|
57
|
-
`;
|
|
58
|
-
await actAndFlush(() => document.body.append(container));
|
|
59
|
-
|
|
60
|
-
const island = container.querySelector<HTMLElement>(
|
|
61
|
-
ISLAND_TAG_NAMES.ISLAND,
|
|
62
|
-
);
|
|
63
|
-
expect(island).not.toBeNull();
|
|
64
|
-
expect(island?.getAttribute("data-status")).toBeNull();
|
|
65
|
-
|
|
66
|
-
await actAndFlush(() => {
|
|
67
|
-
parseMarimoIslandApps(container);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
expect(island?.textContent).toContain("outgoing runtime output");
|
|
71
|
-
|
|
72
|
-
await actAndFlush(() => {
|
|
73
|
-
setNotebook("runtime-cell");
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
expect(island?.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("0");
|
|
77
|
-
expect(island?.getAttribute("data-status")).toBe("idle");
|
|
78
|
-
|
|
79
|
-
await actAndFlush(() => {
|
|
80
|
-
island?.remove();
|
|
81
|
-
if (island) {
|
|
82
|
-
container.append(island);
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
expect(island?.textContent).toContain("initial output");
|
|
86
|
-
|
|
87
|
-
island?.setAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID, "source-cell");
|
|
88
|
-
island?.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "false");
|
|
89
|
-
expect((island as MarimoIslandElement | null)?.cellId).toBeUndefined();
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("refreshes a reused payload island and clears an old app binding", async () => {
|
|
93
|
-
const container = document.createElement("div");
|
|
94
|
-
container.innerHTML = `
|
|
95
|
-
<marimo-island
|
|
96
|
-
data-app-id="app-1"
|
|
97
|
-
data-cell-id="cell-1"
|
|
98
|
-
data-reactive="true"
|
|
99
|
-
>
|
|
100
|
-
<marimo-cell-output><div>old output</div></marimo-cell-output>
|
|
101
|
-
<marimo-cell-code hidden>${encodeURIComponent('print("old")')}</marimo-cell-code>
|
|
102
|
-
</marimo-island>
|
|
103
|
-
`;
|
|
104
|
-
const script = document.createElement("script");
|
|
105
|
-
script.type = ISLANDS_JSON_SCRIPT_TYPE;
|
|
106
|
-
script.textContent = payloadSource({
|
|
107
|
-
code: 'print("unchanged")',
|
|
108
|
-
outputHtml: "<div>old output</div>",
|
|
109
|
-
});
|
|
110
|
-
container.append(script);
|
|
111
|
-
await actAndFlush(() => document.body.append(container));
|
|
112
|
-
|
|
113
|
-
await actAndFlush(() => {
|
|
114
|
-
parseMarimoIslandApps(container);
|
|
115
|
-
script.textContent = payloadSource({
|
|
116
|
-
code: 'print("unchanged")',
|
|
117
|
-
outputHtml: "<div>updated output</div>",
|
|
118
|
-
});
|
|
119
|
-
parseMarimoIslandApps(container);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
const island = container.querySelector<HTMLElement>(
|
|
123
|
-
ISLAND_TAG_NAMES.ISLAND,
|
|
124
|
-
);
|
|
125
|
-
expect(island?.textContent).toContain("updated output");
|
|
126
|
-
expect(island?.textContent).not.toContain("old output");
|
|
127
|
-
expect(island?.querySelector(ISLAND_TAG_NAMES.CELL_OUTPUT)).toBeNull();
|
|
128
|
-
|
|
129
|
-
await actAndFlush(() => {
|
|
130
|
-
setNotebook("outgoing-cell", "outgoing runtime output");
|
|
131
|
-
});
|
|
132
|
-
expect(island?.textContent).toContain("outgoing runtime output");
|
|
133
|
-
|
|
134
|
-
await actAndFlush(() => {
|
|
135
|
-
island?.setAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID, "app-2");
|
|
136
|
-
island?.setAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID, "cell-2");
|
|
137
|
-
script.textContent = payloadSource({
|
|
138
|
-
appId: "app-2",
|
|
139
|
-
cellId: "cell-2",
|
|
140
|
-
code: 'print("next")',
|
|
141
|
-
outputHtml: "<div>next app output</div>",
|
|
142
|
-
});
|
|
143
|
-
parseMarimoIslandApps(container);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
expect(island?.textContent).toContain("next app output");
|
|
147
|
-
expect(island?.textContent).not.toContain("outgoing runtime output");
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
function setNotebook(id: string, output?: string): void {
|
|
152
|
-
const runtimeCellId = cellId(id);
|
|
153
|
-
store.set(
|
|
154
|
-
notebookAtom,
|
|
155
|
-
MockNotebook.notebookState({
|
|
156
|
-
cellData: { [runtimeCellId]: {} },
|
|
157
|
-
cellRuntime: output
|
|
158
|
-
? {
|
|
159
|
-
[runtimeCellId]: {
|
|
160
|
-
output: {
|
|
161
|
-
channel: "output",
|
|
162
|
-
data: `<div>${output}</div>`,
|
|
163
|
-
mimetype: "text/html",
|
|
164
|
-
timestamp: 0,
|
|
165
|
-
},
|
|
166
|
-
},
|
|
167
|
-
}
|
|
168
|
-
: undefined,
|
|
169
|
-
}),
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function payloadSource({
|
|
174
|
-
appId = "app-1",
|
|
175
|
-
cellId = "cell-1",
|
|
176
|
-
code,
|
|
177
|
-
outputHtml,
|
|
178
|
-
}: {
|
|
179
|
-
appId?: string;
|
|
180
|
-
cellId?: string;
|
|
181
|
-
code: string;
|
|
182
|
-
outputHtml: string;
|
|
183
|
-
}): string {
|
|
184
|
-
return JSON.stringify({
|
|
185
|
-
schemaVersion: 1,
|
|
186
|
-
appId,
|
|
187
|
-
cells: [
|
|
188
|
-
{
|
|
189
|
-
cellId,
|
|
190
|
-
code,
|
|
191
|
-
outputHtml,
|
|
192
|
-
outputMimetype: "text/html",
|
|
193
|
-
reactive: true,
|
|
194
|
-
displayCode: false,
|
|
195
|
-
displayOutput: true,
|
|
196
|
-
},
|
|
197
|
-
],
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async function actAndFlush(action: () => void): Promise<void> {
|
|
202
|
-
await act(async () => {
|
|
203
|
-
action();
|
|
204
|
-
await Promise.resolve();
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function setReactActEnvironment(value: boolean): void {
|
|
209
|
-
(
|
|
210
|
-
globalThis as typeof globalThis & {
|
|
211
|
-
IS_REACT_ACT_ENVIRONMENT: boolean;
|
|
212
|
-
}
|
|
213
|
-
).IS_REACT_ACT_ENVIRONMENT = value;
|
|
214
|
-
}
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
|
|
3
|
-
import type { PyodideInterface } from "pyodide";
|
|
4
|
-
import { describe, expect, it, vi } from "vitest";
|
|
5
|
-
import { ReadonlyWasmController } from "../controller";
|
|
6
|
-
|
|
7
|
-
class TestController extends ReadonlyWasmController {
|
|
8
|
-
setPyodide(pyodide: PyodideInterface) {
|
|
9
|
-
this.pyodide = pyodide;
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function createSessionResources(
|
|
14
|
-
stopImplementation: () => Promise<void> = () => Promise.resolve(),
|
|
15
|
-
) {
|
|
16
|
-
const bridge = { destroy: vi.fn() };
|
|
17
|
-
const init = Object.assign(vi.fn(), { destroy: vi.fn() });
|
|
18
|
-
const stop = Object.assign(vi.fn(stopImplementation), {
|
|
19
|
-
destroy: vi.fn(),
|
|
20
|
-
});
|
|
21
|
-
const packages = { destroy: vi.fn(), toJs: () => [] };
|
|
22
|
-
const sessionResources = Object.assign([bridge, init, packages, stop], {
|
|
23
|
-
destroy: vi.fn(),
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
return { bridge, init, packages, sessionResources, stop };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function createPyodideStub(
|
|
30
|
-
sessions = Array.from({ length: 4 }, () => createSessionResources()),
|
|
31
|
-
) {
|
|
32
|
-
const [first] = sessions;
|
|
33
|
-
if (!first) {
|
|
34
|
-
throw new Error("At least one session is required");
|
|
35
|
-
}
|
|
36
|
-
const loadPackagesFromImports = vi.fn().mockResolvedValue(undefined);
|
|
37
|
-
const pyodide = {
|
|
38
|
-
runPython: vi
|
|
39
|
-
.fn()
|
|
40
|
-
.mockImplementation(() => sessions.shift()?.sessionResources),
|
|
41
|
-
loadPackagesFromImports,
|
|
42
|
-
loadedPackages: {},
|
|
43
|
-
runPythonAsync: vi.fn(),
|
|
44
|
-
} as unknown as PyodideInterface;
|
|
45
|
-
|
|
46
|
-
return {
|
|
47
|
-
...first,
|
|
48
|
-
loadPackagesFromImports,
|
|
49
|
-
pyodide,
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function startSession(controller: TestController, code: string) {
|
|
54
|
-
return controller.startSession({
|
|
55
|
-
code,
|
|
56
|
-
filename: `${code}.py`,
|
|
57
|
-
onMessage: vi.fn(),
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
describe("WASM controller session lifecycle", () => {
|
|
62
|
-
it("stops and releases the active session", async () => {
|
|
63
|
-
const { init, packages, pyodide, sessionResources, stop } =
|
|
64
|
-
createPyodideStub();
|
|
65
|
-
const controller = new TestController();
|
|
66
|
-
controller.setPyodide(pyodide);
|
|
67
|
-
|
|
68
|
-
await startSession(controller, "current");
|
|
69
|
-
await vi.waitFor(() => expect(init).toHaveBeenCalledOnce());
|
|
70
|
-
await controller.stopSession();
|
|
71
|
-
|
|
72
|
-
expect(stop).toHaveBeenCalledOnce();
|
|
73
|
-
expect(stop.destroy).toHaveBeenCalledOnce();
|
|
74
|
-
expect(sessionResources.destroy).toHaveBeenCalledOnce();
|
|
75
|
-
expect(packages.destroy).toHaveBeenCalledOnce();
|
|
76
|
-
expect(init.destroy).toHaveBeenCalledOnce();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("loads dependencies serially and skips superseded sessions", async () => {
|
|
80
|
-
let finishFirstLoad!: () => void;
|
|
81
|
-
const { loadPackagesFromImports, pyodide } = createPyodideStub();
|
|
82
|
-
loadPackagesFromImports
|
|
83
|
-
.mockReturnValueOnce(
|
|
84
|
-
new Promise<void>((resolve) => {
|
|
85
|
-
finishFirstLoad = resolve;
|
|
86
|
-
}),
|
|
87
|
-
)
|
|
88
|
-
.mockResolvedValueOnce(undefined);
|
|
89
|
-
const controller = new TestController();
|
|
90
|
-
controller.setPyodide(pyodide);
|
|
91
|
-
|
|
92
|
-
await startSession(controller, "first_dependency");
|
|
93
|
-
await startSession(controller, "superseded_dependency");
|
|
94
|
-
await controller.stopSession();
|
|
95
|
-
await startSession(controller, "current_dependency");
|
|
96
|
-
|
|
97
|
-
expect(loadPackagesFromImports).toHaveBeenCalledOnce();
|
|
98
|
-
finishFirstLoad();
|
|
99
|
-
await vi.waitFor(() =>
|
|
100
|
-
expect(loadPackagesFromImports).toHaveBeenCalledTimes(2),
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
const loadedSources = loadPackagesFromImports.mock.calls.map(
|
|
104
|
-
([source]) => source as string,
|
|
105
|
-
);
|
|
106
|
-
expect(loadedSources[1]).toContain("current_dependency");
|
|
107
|
-
expect(loadedSources.join("\n")).not.toContain("superseded_dependency");
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("stops every session created for a multi-app page", async () => {
|
|
111
|
-
const first = createSessionResources();
|
|
112
|
-
const second = createSessionResources();
|
|
113
|
-
const { pyodide } = createPyodideStub([first, second]);
|
|
114
|
-
const controller = new TestController();
|
|
115
|
-
controller.setPyodide(pyodide);
|
|
116
|
-
|
|
117
|
-
await startSession(controller, "first");
|
|
118
|
-
await startSession(controller, "second");
|
|
119
|
-
second.bridge.destroy();
|
|
120
|
-
|
|
121
|
-
expect(first.stop.destroy).not.toHaveBeenCalled();
|
|
122
|
-
|
|
123
|
-
await controller.stopSession();
|
|
124
|
-
|
|
125
|
-
expect(first.stop).toHaveBeenCalledOnce();
|
|
126
|
-
expect(second.stop).toHaveBeenCalledOnce();
|
|
127
|
-
expect(first.stop.destroy).toHaveBeenCalledOnce();
|
|
128
|
-
expect(second.stop.destroy).toHaveBeenCalledOnce();
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it("continues stopping sessions after one stop fails", async () => {
|
|
132
|
-
const failure = new Error("stop failed");
|
|
133
|
-
const first = createSessionResources(() => Promise.reject(failure));
|
|
134
|
-
const second = createSessionResources();
|
|
135
|
-
const { pyodide } = createPyodideStub([first, second]);
|
|
136
|
-
const controller = new TestController();
|
|
137
|
-
controller.setPyodide(pyodide);
|
|
138
|
-
|
|
139
|
-
await startSession(controller, "first");
|
|
140
|
-
await startSession(controller, "second");
|
|
141
|
-
|
|
142
|
-
await expect(controller.stopSession()).rejects.toThrow("stop failed");
|
|
143
|
-
expect(first.stop).toHaveBeenCalledOnce();
|
|
144
|
-
expect(second.stop).toHaveBeenCalledOnce();
|
|
145
|
-
expect(first.stop.destroy).not.toHaveBeenCalled();
|
|
146
|
-
expect(second.stop.destroy).toHaveBeenCalledOnce();
|
|
147
|
-
|
|
148
|
-
first.stop.mockResolvedValueOnce(undefined);
|
|
149
|
-
await controller.stopSession();
|
|
150
|
-
|
|
151
|
-
expect(first.stop).toHaveBeenCalledTimes(2);
|
|
152
|
-
expect(first.stop.destroy).toHaveBeenCalledOnce();
|
|
153
|
-
expect(second.stop).toHaveBeenCalledOnce();
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it("reports and retries a falsy stop failure", async () => {
|
|
157
|
-
const session = createSessionResources(() => Promise.reject(false));
|
|
158
|
-
const { pyodide } = createPyodideStub([session]);
|
|
159
|
-
const controller = new TestController();
|
|
160
|
-
controller.setPyodide(pyodide);
|
|
161
|
-
|
|
162
|
-
await startSession(controller, "current");
|
|
163
|
-
|
|
164
|
-
await expect(controller.stopSession()).rejects.toBe(false);
|
|
165
|
-
expect(session.stop.destroy).not.toHaveBeenCalled();
|
|
166
|
-
|
|
167
|
-
session.stop.mockResolvedValueOnce(undefined);
|
|
168
|
-
await controller.stopSession();
|
|
169
|
-
|
|
170
|
-
expect(session.stop).toHaveBeenCalledTimes(2);
|
|
171
|
-
expect(session.stop.destroy).toHaveBeenCalledOnce();
|
|
172
|
-
});
|
|
173
|
-
});
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
import { renderHook } from "@testing-library/react";
|
|
3
|
-
import { createStore, Provider } from "jotai";
|
|
4
|
-
import { describe, expect, it, vi } from "vitest";
|
|
5
|
-
import { Functions } from "@/utils/functions";
|
|
6
|
-
import { useHotkey } from "../useHotkey";
|
|
7
|
-
|
|
8
|
-
// global.hideCode is bound to "Mod-." by default; "mod" accepts ctrl or meta.
|
|
9
|
-
const SHORTCUT = "global.hideCode";
|
|
10
|
-
|
|
11
|
-
function createWrapper() {
|
|
12
|
-
const store = createStore();
|
|
13
|
-
return ({ children }: { children: React.ReactNode }) => (
|
|
14
|
-
<Provider store={store}>{children}</Provider>
|
|
15
|
-
);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function press() {
|
|
19
|
-
const event = new KeyboardEvent("keydown", {
|
|
20
|
-
key: ".",
|
|
21
|
-
ctrlKey: true,
|
|
22
|
-
cancelable: true,
|
|
23
|
-
bubbles: true,
|
|
24
|
-
});
|
|
25
|
-
document.dispatchEvent(event);
|
|
26
|
-
return event;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
describe("useHotkey", () => {
|
|
30
|
-
it("invokes the callback and prevents default", () => {
|
|
31
|
-
const callback = vi.fn();
|
|
32
|
-
renderHook(() => useHotkey(SHORTCUT, callback), {
|
|
33
|
-
wrapper: createWrapper(),
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
const event = press();
|
|
37
|
-
expect(callback).toHaveBeenCalledOnce();
|
|
38
|
-
expect(event.defaultPrevented).toBe(true);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("does not prevent default when the callback returns false", () => {
|
|
42
|
-
const callback = vi.fn(() => false);
|
|
43
|
-
renderHook(() => useHotkey(SHORTCUT, callback), {
|
|
44
|
-
wrapper: createWrapper(),
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
const event = press();
|
|
48
|
-
expect(callback).toHaveBeenCalledOnce();
|
|
49
|
-
expect(event.defaultPrevented).toBe(false);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("still swallows the keystroke for NOOP catch-all handlers", () => {
|
|
53
|
-
renderHook(() => useHotkey(SHORTCUT, Functions.NOOP), {
|
|
54
|
-
wrapper: createWrapper(),
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
const event = press();
|
|
58
|
-
expect(event.defaultPrevented).toBe(true);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("neither runs the callback nor prevents default when disabled", () => {
|
|
62
|
-
const callback = vi.fn();
|
|
63
|
-
renderHook(() => useHotkey(SHORTCUT, callback, { disabled: true }), {
|
|
64
|
-
wrapper: createWrapper(),
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
const event = press();
|
|
68
|
-
expect(callback).not.toHaveBeenCalled();
|
|
69
|
-
expect(event.defaultPrevented).toBe(false);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("re-enables when disabled flips back to false", () => {
|
|
73
|
-
const callback = vi.fn();
|
|
74
|
-
const { rerender } = renderHook(
|
|
75
|
-
({ disabled }: { disabled: boolean }) =>
|
|
76
|
-
useHotkey(SHORTCUT, callback, { disabled }),
|
|
77
|
-
{ wrapper: createWrapper(), initialProps: { disabled: true } },
|
|
78
|
-
);
|
|
79
|
-
|
|
80
|
-
press();
|
|
81
|
-
expect(callback).not.toHaveBeenCalled();
|
|
82
|
-
|
|
83
|
-
rerender({ disabled: false });
|
|
84
|
-
const event = press();
|
|
85
|
-
expect(callback).toHaveBeenCalledOnce();
|
|
86
|
-
expect(event.defaultPrevented).toBe(true);
|
|
87
|
-
});
|
|
88
|
-
});
|