@marimo-team/frontend 0.23.1-dev9 → 0.23.2-dev25

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 (68) hide show
  1. package/dist/assets/{JsonOutput-BY31ccA7.js → JsonOutput-CavtrueA.js} +1 -1
  2. package/dist/assets/{MarimoErrorOutput--Yd2Aw0J.js → MarimoErrorOutput-Bmp8DLLo.js} +1 -1
  3. package/dist/assets/RenderHTML-CM3WMmA8.js +1 -0
  4. package/dist/assets/{add-connection-dialog-CjvNOKgb.js → add-connection-dialog-BGZvJkor.js} +1 -1
  5. package/dist/assets/{agent-panel-C24uwabG.js → agent-panel-BvL9Lu9c.js} +1 -1
  6. package/dist/assets/{cell-editor-zW0u82sK.js → cell-editor-B40o_zx_.js} +1 -1
  7. package/dist/assets/{chat-display-DsHMZa9F.js → chat-display-M_nvYuHH.js} +1 -1
  8. package/dist/assets/{chat-panel-o9D3upnX.js → chat-panel-BMOW93uQ.js} +1 -1
  9. package/dist/assets/{chat-ui-BYS03y86.js → chat-ui-DyeimpVh.js} +1 -1
  10. package/dist/assets/{column-preview-Dwv5a_zE.js → column-preview-AfcgbFG_.js} +1 -1
  11. package/dist/assets/{command-palette-BYbKGSF3.js → command-palette-BgvdyU3B.js} +1 -1
  12. package/dist/assets/{documentation-panel-CA2pWMgB.js → documentation-panel-DUPcsi8P.js} +1 -1
  13. package/dist/assets/{edit-page-CMUN3ESy.js → edit-page-DD4uEDmX.js} +4 -4
  14. package/dist/assets/{error-panel-CbqfK1HJ.js → error-panel-DQOeSv5-.js} +1 -1
  15. package/dist/assets/{file-explorer-panel-CbS8z-JR.js → file-explorer-panel-B67zjs2X.js} +1 -1
  16. package/dist/assets/{form-DLyXacSF.js → form-BJ6VFU8l.js} +1 -1
  17. package/dist/assets/{hooks-kZJc1iBf.js → hooks-DvwShzDb.js} +1 -1
  18. package/dist/assets/index-y6osgSWB.js +42 -0
  19. package/dist/assets/{layout-tmN-U1zs.js → layout-erv8pLIP.js} +1 -1
  20. package/dist/assets/{panels-CLfdzLPR.js → panels-1u-RE72f.js} +1 -1
  21. package/dist/assets/{run-page-DPuH6QY4.js → run-page-DfWH_1mz.js} +1 -1
  22. package/dist/assets/{scratchpad-panel-BsMm0GQP.js → scratchpad-panel-CnaiXtoJ.js} +1 -1
  23. package/dist/assets/{session-panel-CTDzGShO.js → session-panel-C68GBFwH.js} +1 -1
  24. package/dist/assets/{snippets-panel-CWof0wHk.js → snippets-panel-BmIdR0lc.js} +1 -1
  25. package/dist/assets/state-D1n-olwf.js +3 -0
  26. package/dist/assets/{useNotebookActions-DHBEqrc_.js → useNotebookActions-Ch1o32Jw.js} +1 -1
  27. package/dist/index.html +7 -7
  28. package/package.json +4 -4
  29. package/src/core/islands/__tests__/bridge.test.ts +2 -12
  30. package/src/core/islands/__tests__/islands-harness.test.ts +348 -0
  31. package/src/core/islands/__tests__/parse.test.ts +466 -24
  32. package/src/core/islands/__tests__/test-utils.tsx +263 -0
  33. package/src/core/islands/bootstrap.ts +265 -0
  34. package/src/core/islands/bridge.ts +154 -75
  35. package/src/core/islands/components/IslandControls.tsx +103 -0
  36. package/src/core/islands/components/__tests__/IslandControls.test.tsx +185 -0
  37. package/src/core/islands/components/__tests__/useIslandControls.test.ts +208 -0
  38. package/src/core/islands/components/output-wrapper.tsx +76 -93
  39. package/src/core/islands/components/useIslandControls.ts +60 -0
  40. package/src/core/islands/components/web-components.tsx +168 -40
  41. package/src/core/islands/constants.ts +28 -0
  42. package/src/core/islands/main.ts +7 -205
  43. package/src/core/islands/parse.ts +73 -26
  44. package/src/core/islands/worker-factory.ts +86 -0
  45. package/src/plugins/core/RenderHTML.tsx +9 -0
  46. package/src/plugins/core/__test__/RenderHTML.test.ts +27 -0
  47. package/src/plugins/core/__test__/trusted-url.test.ts +48 -0
  48. package/src/plugins/core/registerReactComponent.tsx +11 -8
  49. package/src/plugins/core/trusted-url.ts +20 -0
  50. package/src/plugins/impl/ButtonPlugin.tsx +4 -6
  51. package/src/plugins/impl/CodeEditorPlugin.tsx +15 -18
  52. package/src/plugins/impl/DataEditorPlugin.tsx +8 -14
  53. package/src/plugins/impl/DataTablePlugin.tsx +8 -9
  54. package/src/plugins/impl/FileUploadPlugin.tsx +39 -43
  55. package/src/plugins/impl/FormPlugin.tsx +2 -6
  56. package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +27 -1
  57. package/src/plugins/impl/anywidget/widget-binding.ts +13 -0
  58. package/src/plugins/impl/chat/ChatPlugin.tsx +17 -20
  59. package/src/plugins/impl/data-explorer/DataExplorerPlugin.tsx +5 -8
  60. package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +21 -0
  61. package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +119 -0
  62. package/src/plugins/impl/panel/PanelPlugin.tsx +31 -10
  63. package/src/plugins/impl/panel/__tests__/PanelPlugin.test.ts +60 -0
  64. package/src/plugins/impl/vega/VegaPlugin.tsx +5 -8
  65. package/src/plugins/layout/NavigationMenuPlugin.tsx +2 -6
  66. package/dist/assets/RenderHTML-CbuarQqA.js +0 -1
  67. package/dist/assets/index-Bm25ctN7.js +0 -42
  68. package/dist/assets/state-BvnlMKdT.js +0 -3
@@ -8,100 +8,154 @@ import type { JsonString } from "@/utils/json/base64";
8
8
  import { Logger } from "@/utils/Logger";
9
9
  import { generateUUID } from "@/utils/uuid";
10
10
  import type { CommandMessage, NotificationPayload } from "../kernel/messages";
11
- import { getMarimoVersion } from "../meta/globals";
12
11
  import type { EditRequests, RunRequests } from "../network/types";
13
- import { store } from "../state/jotai";
14
-
12
+ import { store as defaultStore } from "../state/jotai";
15
13
  import { createMarimoFile, parseMarimoIslandApps } from "./parse";
16
14
  import { islandsInitializedAtom } from "./state";
17
15
  import type { WorkerSchema } from "./worker/worker";
18
- import workerUrl from "./worker/worker.tsx?worker&url";
16
+ import type { WorkerFactory } from "./worker-factory";
17
+ import { DefaultWorkerFactory } from "./worker-factory";
19
18
 
20
- export class IslandsPyodideBridge implements RunRequests, EditRequests {
19
+ /**
20
+ * Configuration for creating an IslandsPyodideBridge
21
+ */
22
+ export interface IslandsBridgeConfig {
21
23
  /**
22
- * Lazy singleton instance of the IslandsPyodideBridge.
24
+ * Optional worker factory for creating workers (for testing)
23
25
  */
24
- static get INSTANCE(): IslandsPyodideBridge {
25
- const KEY = "_marimo_private_IslandsPyodideBridge";
26
- if (!window[KEY]) {
27
- window[KEY] = new IslandsPyodideBridge();
28
- }
29
- return window[KEY] as IslandsPyodideBridge;
30
- }
26
+ workerFactory?: WorkerFactory;
31
27
 
28
+ /**
29
+ * Optional Jotai store (for testing)
30
+ */
31
+ store?: typeof defaultStore;
32
+
33
+ /**
34
+ * Optional root element for parsing islands (for testing)
35
+ */
36
+ root?: Document | Element;
37
+
38
+ /**
39
+ * Whether to auto-start sessions on worker ready (default: true)
40
+ */
41
+ autoStartSessions?: boolean;
42
+ }
43
+
44
+ /**
45
+ * Bridge between the browser and Pyodide worker for islands mode.
46
+ *
47
+ * This class manages communication with a Web Worker that runs Python code
48
+ * via Pyodide, enabling interactive marimo islands.
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * const bridge = new IslandsPyodideBridge();
53
+ * await bridge.initialized;
54
+ * bridge.consumeMessages(message => console.log(message));
55
+ * ```
56
+ */
57
+ export class IslandsPyodideBridge implements RunRequests, EditRequests {
32
58
  private rpc: ReturnType<typeof getWorkerRPC<WorkerSchema>>;
33
59
  private messageConsumer:
34
60
  | ((message: JsonString<NotificationPayload>) => void)
35
61
  | undefined;
62
+ private readonly store: typeof defaultStore;
63
+ private readonly root: Document | Element;
64
+ private readonly autoStartSessions: boolean;
36
65
 
37
66
  public initialized = new Deferred<void>();
38
67
 
39
- private constructor() {
40
- // TODO: abstract out into a worker constructor
41
-
42
- // . in front of workerUrl is necessary to make it a relative import
43
- const url = import.meta.env.DEV
44
- ? workerUrl
45
- : makeRelativeWorkerUrl(workerUrl);
46
- const js = `import ${JSON.stringify(new URL(url, import.meta.url))}`;
47
- const blob = new Blob([js], { type: "application/javascript" });
48
- const objURL = URL.createObjectURL(blob);
49
- const worker = new Worker(
50
- // oxlint-disable-next-line unicorn/relative-url-style
51
- objURL,
52
- {
53
- type: "module",
54
- // Pass the version to the worker
55
- /* @vite-ignore */
56
- name: getMarimoVersion(),
57
- },
58
- );
68
+ constructor(config: IslandsBridgeConfig = {}) {
69
+ this.store = config.store || defaultStore;
70
+ this.root = config.root || document;
71
+ this.autoStartSessions = config.autoStartSessions ?? true;
59
72
 
60
- worker.addEventListener("error", (e) => {
61
- // Fallback to cleaning up created object URL
62
- URL.revokeObjectURL(objURL);
63
- });
64
-
65
- // Create the RPC
66
- this.rpc = getWorkerRPC<WorkerSchema>(worker);
73
+ try {
74
+ const factory = config.workerFactory || new DefaultWorkerFactory();
75
+ const worker = factory.create();
76
+ this.rpc = getWorkerRPC<WorkerSchema>(worker);
77
+ this.setupMessageListeners();
78
+ } catch (error) {
79
+ Logger.error("Failed to initialize IslandsPyodideBridge:", error);
80
+ this.initialized.reject(error);
81
+ throw error;
82
+ }
83
+ }
67
84
 
68
- // Listeners
69
- const apps = parseMarimoIslandApps();
85
+ /**
86
+ * Sets up message listeners for worker communication
87
+ */
88
+ private setupMessageListeners(): void {
70
89
  this.rpc.addMessageListener("ready", () => {
71
- for (const app of apps) {
72
- Logger.debug("Starting session for app", app.id);
73
- const file = createMarimoFile(app);
74
- Logger.debug(file);
75
- this.startSession({
76
- code: file,
77
- appId: app.id,
78
- });
90
+ if (this.autoStartSessions) {
91
+ this.startSessionsForAllApps();
79
92
  }
80
93
  });
94
+
81
95
  this.rpc.addMessageListener("initialized", () => {
82
- store.set(islandsInitializedAtom, true);
96
+ this.store.set(islandsInitializedAtom, true);
83
97
  this.initialized.resolve();
84
98
  });
85
- this.rpc.addMessageListener("initializedError", ({ error }) => {
86
- store.set(islandsInitializedAtom, error);
87
- this.initialized.reject(new Error(error));
88
- });
89
- this.rpc.addMessageListener("kernelMessage", ({ message }) => {
90
- this.messageConsumer?.(message);
91
- });
99
+
100
+ this.rpc.addMessageListener(
101
+ "initializedError",
102
+ ({ error }: { error: string }) => {
103
+ Logger.error("Islands initialization error:", error);
104
+ this.store.set(islandsInitializedAtom, error);
105
+ this.initialized.reject(new Error(error));
106
+ },
107
+ );
108
+
109
+ this.rpc.addMessageListener(
110
+ "kernelMessage",
111
+ ({ message }: { message: JsonString<NotificationPayload> }) => {
112
+ this.messageConsumer?.(message);
113
+ },
114
+ );
92
115
  }
93
116
 
94
- async startSession(opts: { code: string; appId: string }) {
117
+ /**
118
+ * Starts sessions for all apps found in the DOM
119
+ */
120
+ private startSessionsForAllApps(): void {
121
+ const apps = parseMarimoIslandApps(this.root);
122
+ Logger.debug(
123
+ `Starting sessions for ${apps.length} app(s):`,
124
+ apps.map((a) => `${a.id} (${a.cells.length} cells)`),
125
+ );
126
+ for (const app of apps) {
127
+ const file = createMarimoFile(app);
128
+ Logger.debug(`App ${app.id} marimo file:\n`, file);
129
+ this.startSession({
130
+ code: file,
131
+ appId: app.id,
132
+ }).catch((error) => {
133
+ Logger.error(`Failed to start session for app ${app.id}:`, error);
134
+ });
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Starts a new Python session for an app
140
+ */
141
+ async startSession(opts: { code: string; appId: string }): Promise<void> {
95
142
  await this.rpc.proxy.request.startSession(opts);
96
143
  }
97
144
 
145
+ /**
146
+ * Sets up a consumer for kernel messages
147
+ */
98
148
  consumeMessages(
99
149
  consumer: (message: JsonString<NotificationPayload>) => void,
100
- ) {
150
+ ): void {
101
151
  this.messageConsumer = consumer;
102
152
  this.rpc.proxy.send.consumerReady({});
103
153
  }
104
154
 
155
+ // ============================================================================
156
+ // RunRequests Implementation
157
+ // ============================================================================
158
+
105
159
  sendComponentValues: RunRequests["sendComponentValues"] = async (
106
160
  request,
107
161
  ): Promise<null> => {
@@ -113,9 +167,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
113
167
  return null;
114
168
  };
115
169
 
116
- sendInstantiate: RunRequests["sendInstantiate"] = async (
117
- request,
118
- ): Promise<null> => {
170
+ sendInstantiate: RunRequests["sendInstantiate"] = async (): Promise<null> => {
119
171
  return null;
120
172
  };
121
173
 
@@ -129,23 +181,31 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
129
181
  return null;
130
182
  };
131
183
 
132
- sendRun: EditRequests["sendRun"] = async (request): Promise<null> => {
133
- await this.rpc.proxy.request.loadPackages(request.codes.join("\n"));
184
+ sendModelValue: RunRequests["sendModelValue"] = async (request) => {
134
185
  await this.putControlRequest({
135
- type: "execute-cells",
186
+ type: "model",
136
187
  ...request,
137
188
  });
138
189
  return null;
139
190
  };
140
191
 
141
- sendModelValue: RunRequests["sendModelValue"] = async (request) => {
192
+ // ============================================================================
193
+ // EditRequests Implementation
194
+ // ============================================================================
195
+
196
+ sendRun: EditRequests["sendRun"] = async (request): Promise<null> => {
197
+ await this.rpc.proxy.request.loadPackages(request.codes.join("\n"));
142
198
  await this.putControlRequest({
143
- type: "model",
199
+ type: "execute-cells",
144
200
  ...request,
145
201
  });
146
202
  return null;
147
203
  };
148
204
 
205
+ // ============================================================================
206
+ // Not Implemented (Read-Only Mode)
207
+ // ============================================================================
208
+
149
209
  getUsageStats = throwNotImplemented;
150
210
  sendRename = throwNotImplemented;
151
211
  sendSave = throwNotImplemented;
@@ -207,18 +267,37 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
207
267
 
208
268
  // The kernel uses msgspec to parse control requests, which requires a 'type'
209
269
  // field for discriminated union deserialization.
210
- private async putControlRequest(operation: CommandMessage) {
270
+ private async putControlRequest(operation: CommandMessage): Promise<void> {
211
271
  await this.rpc.proxy.request.bridge({
212
272
  functionName: "put_control_request",
213
273
  payload: operation,
214
274
  });
215
275
  }
276
+
277
+ /**
278
+ * Cleans up resources (for testing)
279
+ */
280
+ destroy(): void {
281
+ // Future: terminate worker if we own it
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Global singleton instance.
287
+ * Use `new IslandsPyodideBridge(config)` in tests for better isolation.
288
+ */
289
+ let globalBridgeInstance: IslandsPyodideBridge | null = null;
290
+
291
+ export function getGlobalBridge(): IslandsPyodideBridge {
292
+ if (!globalBridgeInstance) {
293
+ globalBridgeInstance = new IslandsPyodideBridge();
294
+ }
295
+ return globalBridgeInstance;
216
296
  }
217
297
 
218
- function makeRelativeWorkerUrl(url: string) {
219
- return url.startsWith("./")
220
- ? url
221
- : url.startsWith("/")
222
- ? `.${url}`
223
- : `./${url}`;
298
+ /**
299
+ * Resets the global bridge instance (for testing)
300
+ */
301
+ export function resetGlobalBridge(): void {
302
+ globalBridgeInstance = null;
224
303
  }
@@ -0,0 +1,103 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { CopyIcon, PlayIcon } from "lucide-react";
4
+ import type { JSX } from "react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Tooltip } from "@/components/ui/tooltip";
7
+ import type { CellId } from "@/core/cells/ids";
8
+ import { useRequestClient } from "@/core/network/requests";
9
+ import { copyToClipboard } from "@/utils/copy";
10
+ import { Logger } from "@/utils/Logger";
11
+
12
+ /**
13
+ * Props for IslandControls component
14
+ */
15
+ export interface IslandControlsProps {
16
+ /**
17
+ * ID of the cell this control operates on
18
+ */
19
+ cellId: CellId;
20
+
21
+ /**
22
+ * Callback to get the current code for the cell
23
+ */
24
+ codeCallback: () => string;
25
+
26
+ /**
27
+ * Whether the controls should be visible
28
+ */
29
+ visible: boolean;
30
+ }
31
+
32
+ /**
33
+ * Props for individual control buttons
34
+ */
35
+ interface IconButtonProps {
36
+ tooltip: string;
37
+ icon: JSX.Element;
38
+ action: () => void;
39
+ }
40
+
41
+ /**
42
+ * A single icon button with tooltip
43
+ */
44
+ const IconButton: React.FC<IconButtonProps> = ({ tooltip, icon, action }) => (
45
+ <Tooltip content={tooltip} delayDuration={200}>
46
+ <Button
47
+ size="icon"
48
+ variant="outline"
49
+ className="bg-background h-5 w-5 mb-0"
50
+ onClick={action}
51
+ >
52
+ {icon}
53
+ </Button>
54
+ </Tooltip>
55
+ );
56
+
57
+ /**
58
+ * Controls for interacting with an island cell.
59
+ *
60
+ * Provides buttons to:
61
+ * - Copy the cell's code to clipboard
62
+ * - Re-run the cell
63
+ */
64
+ export const IslandControls: React.FC<IslandControlsProps> = ({
65
+ cellId,
66
+ codeCallback,
67
+ visible,
68
+ }) => {
69
+ const { sendRun } = useRequestClient();
70
+
71
+ const handleCopy = () => {
72
+ copyToClipboard(codeCallback());
73
+ };
74
+
75
+ const handleRun = async () => {
76
+ try {
77
+ await sendRun({
78
+ cellIds: [cellId],
79
+ codes: [codeCallback()],
80
+ });
81
+ } catch (error) {
82
+ Logger.error("Failed to run cell:", error);
83
+ }
84
+ };
85
+
86
+ return (
87
+ <div
88
+ className="absolute top-0 right-0 z-50 flex items-center justify-center gap-1"
89
+ style={{ display: visible ? "flex" : "none" }}
90
+ >
91
+ <IconButton
92
+ tooltip="Copy code"
93
+ icon={<CopyIcon className="size-3" />}
94
+ action={handleCopy}
95
+ />
96
+ <IconButton
97
+ tooltip="Re-run cell"
98
+ icon={<PlayIcon className="size-3" />}
99
+ action={handleRun}
100
+ />
101
+ </div>
102
+ );
103
+ };
@@ -0,0 +1,185 @@
1
+ /* Copyright 2024 Marimo. All rights reserved. */
2
+
3
+ import { fireEvent, render, screen } from "@testing-library/react";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { TooltipProvider } from "@/components/ui/tooltip";
6
+ import type { CellId } from "@/core/cells/ids";
7
+ import * as requestsModule from "@/core/network/requests";
8
+ import * as copyModule from "@/utils/copy";
9
+ import { IslandControls } from "../IslandControls";
10
+
11
+ // Mock the dependencies
12
+ vi.mock("@/core/network/requests", () => ({
13
+ useRequestClient: vi.fn(),
14
+ }));
15
+
16
+ vi.mock("@/utils/copy", () => ({
17
+ copyToClipboard: vi.fn(),
18
+ }));
19
+
20
+ describe("IslandControls", () => {
21
+ const mockSendRun = vi.fn();
22
+ const mockCopyToClipboard = vi.fn();
23
+ const mockCodeCallback = vi.fn(() => "print('test code')");
24
+ const cellId = "test-cell-id" as CellId;
25
+
26
+ // Helper to render with required providers
27
+ const renderWithProviders = (component: React.ReactElement) => {
28
+ return render(<TooltipProvider>{component}</TooltipProvider>);
29
+ };
30
+
31
+ beforeEach(() => {
32
+ vi.clearAllMocks();
33
+ mockSendRun.mockResolvedValue(undefined);
34
+
35
+ vi.spyOn(requestsModule, "useRequestClient").mockReturnValue({
36
+ sendRun: mockSendRun,
37
+ } as any);
38
+
39
+ vi.spyOn(copyModule, "copyToClipboard").mockImplementation(
40
+ mockCopyToClipboard,
41
+ );
42
+ });
43
+
44
+ it("should not display when visible is false", () => {
45
+ const { container } = renderWithProviders(
46
+ <IslandControls
47
+ cellId={cellId}
48
+ codeCallback={mockCodeCallback}
49
+ visible={false}
50
+ />,
51
+ );
52
+
53
+ const controlsDiv = container.firstChild as HTMLElement;
54
+ expect(controlsDiv).toBeDefined();
55
+ expect(controlsDiv.style.display).toBe("none");
56
+ });
57
+
58
+ it("should display when visible is true", () => {
59
+ const { container } = renderWithProviders(
60
+ <IslandControls
61
+ cellId={cellId}
62
+ codeCallback={mockCodeCallback}
63
+ visible={true}
64
+ />,
65
+ );
66
+
67
+ const controlsDiv = container.firstChild as HTMLElement;
68
+ expect(controlsDiv.style.display).toBe("flex");
69
+ });
70
+
71
+ it("should render copy and run buttons", () => {
72
+ renderWithProviders(
73
+ <IslandControls
74
+ cellId={cellId}
75
+ codeCallback={mockCodeCallback}
76
+ visible={true}
77
+ />,
78
+ );
79
+
80
+ // Should have 2 buttons (copy and run)
81
+ const buttons = screen.getAllByRole("button");
82
+ expect(buttons).toHaveLength(2);
83
+ });
84
+
85
+ it("should copy code to clipboard when copy button is clicked", async () => {
86
+ renderWithProviders(
87
+ <IslandControls
88
+ cellId={cellId}
89
+ codeCallback={mockCodeCallback}
90
+ visible={true}
91
+ />,
92
+ );
93
+
94
+ const buttons = screen.getAllByRole("button");
95
+ const copyButton = buttons[0]; // First button is copy
96
+
97
+ fireEvent.click(copyButton);
98
+
99
+ expect(mockCodeCallback).toHaveBeenCalled();
100
+ expect(mockCopyToClipboard).toHaveBeenCalledWith("print('test code')");
101
+ });
102
+
103
+ it("should run cell when run button is clicked", async () => {
104
+ renderWithProviders(
105
+ <IslandControls
106
+ cellId={cellId}
107
+ codeCallback={mockCodeCallback}
108
+ visible={true}
109
+ />,
110
+ );
111
+
112
+ const buttons = screen.getAllByRole("button");
113
+ const runButton = buttons[1]; // Second button is run
114
+
115
+ fireEvent.click(runButton);
116
+
117
+ // Wait for async operation
118
+ await vi.waitFor(() => {
119
+ expect(mockCodeCallback).toHaveBeenCalled();
120
+ expect(mockSendRun).toHaveBeenCalledWith({
121
+ cellIds: [cellId],
122
+ codes: ["print('test code')"],
123
+ });
124
+ });
125
+ });
126
+
127
+ it("should handle run errors gracefully", async () => {
128
+ const consoleErrorSpy = vi
129
+ .spyOn(console, "error")
130
+ .mockImplementation(() => {});
131
+
132
+ mockSendRun.mockRejectedValueOnce(new Error("Run failed"));
133
+
134
+ renderWithProviders(
135
+ <IslandControls
136
+ cellId={cellId}
137
+ codeCallback={mockCodeCallback}
138
+ visible={true}
139
+ />,
140
+ );
141
+
142
+ const buttons = screen.getAllByRole("button");
143
+ const runButton = buttons[1];
144
+
145
+ fireEvent.click(runButton);
146
+
147
+ // Wait for error to be logged
148
+ await vi.waitFor(() => {
149
+ expect(consoleErrorSpy).toHaveBeenCalled();
150
+ });
151
+
152
+ consoleErrorSpy.mockRestore();
153
+ });
154
+
155
+ it("should get fresh code on each button click", async () => {
156
+ let callCount = 0;
157
+ const dynamicCodeCallback = vi.fn(() => `code version ${++callCount}`);
158
+
159
+ renderWithProviders(
160
+ <IslandControls
161
+ cellId={cellId}
162
+ codeCallback={dynamicCodeCallback}
163
+ visible={true}
164
+ />,
165
+ );
166
+
167
+ const buttons = screen.getAllByRole("button");
168
+ const copyButton = buttons[0];
169
+ const runButton = buttons[1];
170
+
171
+ fireEvent.click(copyButton);
172
+ expect(mockCopyToClipboard).toHaveBeenCalledWith("code version 1");
173
+
174
+ fireEvent.click(runButton);
175
+ await vi.waitFor(() => {
176
+ expect(mockSendRun).toHaveBeenCalledWith({
177
+ cellIds: [cellId],
178
+ codes: ["code version 2"],
179
+ });
180
+ });
181
+
182
+ fireEvent.click(copyButton);
183
+ expect(mockCopyToClipboard).toHaveBeenCalledWith("code version 3");
184
+ });
185
+ });