@marimo-team/islands 0.24.3-dev36 → 0.24.3-dev39
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/{common-CWnP5Rod.js → common-eNTtBSo2.js} +2 -2
- package/dist/main.js +6 -2
- package/dist/{reveal-component-SdzpGkJs.js → reveal-component-CNqmC7b6.js} +1 -1
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/__mocks__/requests.ts +7 -0
- package/src/components/editor/alerts/connecting-alert.tsx +1 -66
- package/src/components/editor/alerts/connection-notice.tsx +140 -0
- package/src/components/editor/app-container.tsx +9 -3
- package/src/components/editor/chrome/panels/__tests__/packages-panel.test.tsx +114 -7
- package/src/components/editor/chrome/panels/__tests__/sandbox-recovery.test.tsx +165 -0
- package/src/components/editor/chrome/panels/packages-panel.tsx +93 -166
- package/src/components/editor/chrome/panels/sandbox-controller.tsx +106 -0
- package/src/components/editor/chrome/panels/sandbox-panel.tsx +173 -0
- package/src/components/editor/chrome/wrapper/footer-items/backend-status.tsx +3 -1
- package/src/components/editor/header/__tests__/status.test.tsx +8 -20
- package/src/components/editor/header/status.tsx +5 -3
- package/src/components/editor/renderers/cell-array.tsx +1 -5
- package/src/core/MarimoApp.tsx +0 -2
- package/src/core/edit-app.tsx +8 -5
- package/src/core/errors/state.ts +1 -2
- package/src/core/islands/bootstrap.ts +1 -0
- package/src/core/islands/bridge.ts +3 -0
- package/src/core/network/connection-notice.ts +84 -0
- package/src/core/network/requests-lazy.ts +3 -0
- package/src/core/network/requests-network.ts +12 -0
- package/src/core/network/requests-static.ts +3 -0
- package/src/core/network/requests-toasting.tsx +3 -0
- package/src/core/network/resolve.ts +2 -1
- package/src/core/network/types.ts +7 -0
- package/src/core/packages/__tests__/usePackageAction.test.tsx +129 -0
- package/src/core/packages/__tests__/useSandboxController.test.tsx +165 -0
- package/src/core/packages/package-data.ts +35 -0
- package/src/core/packages/sandbox-state.ts +15 -0
- package/src/core/packages/toast-components.tsx +11 -0
- package/src/core/packages/useInstallPackage.ts +1 -1
- package/src/core/packages/usePackageAction.ts +51 -0
- package/src/core/packages/usePackageDependencies.ts +22 -0
- package/src/core/packages/useSandboxController.ts +188 -0
- package/src/core/run-app.tsx +2 -0
- package/src/core/wasm/bridge.ts +11 -0
- package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +212 -2
- package/src/core/websocket/types.ts +11 -1
- package/src/core/websocket/useMarimoKernelConnection.tsx +43 -8
- package/src/stories/sandbox.stories.tsx +308 -0
|
@@ -42,7 +42,9 @@ export const BackendConnectionStatus: React.FC = () => {
|
|
|
42
42
|
const { isFetching, error, data, refetch } = useAsyncData(async () => {
|
|
43
43
|
// If the connection is not connected, return
|
|
44
44
|
if (!isAppConnected(connection)) {
|
|
45
|
-
setConnectionStatus(
|
|
45
|
+
setConnectionStatus(
|
|
46
|
+
isAppConnecting(connection) ? "connecting" : "disconnected",
|
|
47
|
+
);
|
|
46
48
|
return;
|
|
47
49
|
}
|
|
48
50
|
|
|
@@ -66,24 +66,12 @@ describe("StatusOverlay disconnect indicator", () => {
|
|
|
66
66
|
expect((button as HTMLButtonElement).disabled).toBe(true);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
it
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
"
|
|
76
|
-
|
|
77
|
-
const onReconnect = vi.fn();
|
|
78
|
-
const { getByTestId } = renderOverlay(
|
|
79
|
-
{ state: WebSocketState.CLOSED, code, reason },
|
|
80
|
-
onReconnect,
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
const button = getByTestId("disconnected-indicator") as HTMLButtonElement;
|
|
84
|
-
expect(button.disabled).toBe(true);
|
|
85
|
-
fireEvent.click(button);
|
|
86
|
-
expect(onReconnect).not.toHaveBeenCalled();
|
|
87
|
-
},
|
|
88
|
-
);
|
|
69
|
+
it("leaves editor startup failures to the inline notice", () => {
|
|
70
|
+
const { queryByTestId } = renderOverlay({
|
|
71
|
+
state: WebSocketState.CLOSED,
|
|
72
|
+
code: WebSocketClosedReason.KERNEL_STARTUP_ERROR,
|
|
73
|
+
reason: "Failed to start kernel sandbox",
|
|
74
|
+
});
|
|
75
|
+
expect(queryByTestId("disconnected-indicator")).not.toBeInTheDocument();
|
|
76
|
+
});
|
|
89
77
|
});
|
|
@@ -20,10 +20,12 @@ export const StatusOverlay: React.FC<{
|
|
|
20
20
|
onReconnect?: () => void;
|
|
21
21
|
}> = ({ connection, isRunning, onReconnect }) => {
|
|
22
22
|
const { mode } = useAtomValue(viewStateAtom);
|
|
23
|
-
const isClosed =
|
|
23
|
+
const isClosed =
|
|
24
|
+
connection.state === WebSocketState.CLOSED &&
|
|
25
|
+
(mode === "read" ||
|
|
26
|
+
connection.code !== WebSocketClosedReason.KERNEL_STARTUP_ERROR);
|
|
24
27
|
const isOpen = connection.state === WebSocketState.OPEN;
|
|
25
|
-
//
|
|
26
|
-
// would deterministically fail the same way.
|
|
28
|
+
// Editor startup failures have their own recovery UI.
|
|
27
29
|
const canReconnect =
|
|
28
30
|
isClosed && connection.code === WebSocketClosedReason.KERNEL_DISCONNECTED;
|
|
29
31
|
|
|
@@ -43,10 +43,7 @@ import type { AppMode } from "../../../core/mode";
|
|
|
43
43
|
import { useHotkey } from "../../../hooks/useHotkey";
|
|
44
44
|
import { type Theme, useTheme } from "../../../theme/useTheme";
|
|
45
45
|
import { AddCellWithAI } from "../ai/add-cell-with-ai";
|
|
46
|
-
import {
|
|
47
|
-
ConnectingAlert,
|
|
48
|
-
NotStartedConnectionAlert,
|
|
49
|
-
} from "../alerts/connecting-alert";
|
|
46
|
+
import { NotStartedConnectionAlert } from "../alerts/connecting-alert";
|
|
50
47
|
import { FloatingOutline } from "../chrome/panels/outline/floating-outline";
|
|
51
48
|
import { useChromeActions } from "../chrome/state";
|
|
52
49
|
import { Column } from "../columns/cell-column";
|
|
@@ -140,7 +137,6 @@ const CellArrayInternal: React.FC<CellArrayProps> = ({
|
|
|
140
137
|
<PackageAlert />
|
|
141
138
|
<StartupLogsAlert />
|
|
142
139
|
<StdinBlockingAlert />
|
|
143
|
-
<ConnectingAlert />
|
|
144
140
|
<NotebookBanner width={appConfig.width} />
|
|
145
141
|
{/* Only show if not cells, otherwise running a single cell will start the connection */}
|
|
146
142
|
{cellIds.idLength === 0 && <NotStartedConnectionAlert />}
|
package/src/core/MarimoApp.tsx
CHANGED
|
@@ -12,7 +12,6 @@ import { getInitialAppMode } from "@/core/mode";
|
|
|
12
12
|
import { CssVariables } from "@/theme/ThemeProvider";
|
|
13
13
|
import { reactLazyWithPreload } from "@/utils/lazy";
|
|
14
14
|
import { ErrorBoundary } from "../components/editor/boundary/ErrorBoundary";
|
|
15
|
-
import { KernelStartupErrorModal } from "../components/editor/KernelStartupErrorModal";
|
|
16
15
|
import { TracebackModalContainer } from "../components/editor/TracebackModalContainer";
|
|
17
16
|
import { ModalProvider } from "../components/modal/ImperativeModal";
|
|
18
17
|
import { Toaster } from "../components/ui/toaster";
|
|
@@ -105,7 +104,6 @@ const Providers = memo(({ children }: PropsWithChildren) => {
|
|
|
105
104
|
{children}
|
|
106
105
|
<Toaster />
|
|
107
106
|
<TailwindIndicator />
|
|
108
|
-
<KernelStartupErrorModal />
|
|
109
107
|
<TracebackModalContainer />
|
|
110
108
|
</ModalProvider>
|
|
111
109
|
</LocaleProvider>
|
package/src/core/edit-app.tsx
CHANGED
|
@@ -9,7 +9,8 @@ import { useAtomValue, useSetAtom } from "jotai";
|
|
|
9
9
|
import { useEffect } from "react";
|
|
10
10
|
import { NotStartedConnectionAlert } from "@/components/editor/alerts/connecting-alert";
|
|
11
11
|
import { Controls } from "@/components/editor/controls/Controls";
|
|
12
|
-
import {
|
|
12
|
+
import { SandboxController } from "@/components/editor/chrome/panels/sandbox-controller";
|
|
13
|
+
import { ConnectionNotice } from "@/components/editor/alerts/connection-notice";
|
|
13
14
|
import { FilenameForm } from "@/components/editor/header/filename-form";
|
|
14
15
|
import { MultiCellActionToolbar } from "@/components/editor/navigation/multi-cell-action-toolbar";
|
|
15
16
|
import { ViewerBanner } from "@/components/editor/viewer-banner";
|
|
@@ -158,6 +159,7 @@ export const EditApp: React.FC<AppProps> = ({
|
|
|
158
159
|
|
|
159
160
|
return (
|
|
160
161
|
<>
|
|
162
|
+
<SandboxController onReconnect={reconnect} />
|
|
161
163
|
<AppContainer
|
|
162
164
|
connection={connection}
|
|
163
165
|
isRunning={isRunning}
|
|
@@ -165,10 +167,9 @@ export const EditApp: React.FC<AppProps> = ({
|
|
|
165
167
|
onReconnect={reconnect}
|
|
166
168
|
>
|
|
167
169
|
<ViewerBanner />
|
|
168
|
-
<
|
|
169
|
-
connection={connection}
|
|
170
|
+
<div
|
|
170
171
|
className={cn(
|
|
171
|
-
"pt-4 sm:pt-12 pb-
|
|
172
|
+
"pt-4 sm:pt-12 pb-6 print:hidden z-50",
|
|
172
173
|
// Keep the header sticky when scrolling horizontally, for column mode
|
|
173
174
|
"sticky left-0",
|
|
174
175
|
)}
|
|
@@ -178,7 +179,9 @@ export const EditApp: React.FC<AppProps> = ({
|
|
|
178
179
|
<FilenameForm filename={filename} />
|
|
179
180
|
</div>
|
|
180
181
|
)}
|
|
181
|
-
</
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<ConnectionNotice appConfig={appConfig} onRetry={reconnect} />
|
|
182
185
|
|
|
183
186
|
{/* Don't render until we have a single cell. NotStartedConnectionAlert
|
|
184
187
|
still covers the "no remote runtime started" prompt. */}
|
package/src/core/errors/state.ts
CHANGED
|
@@ -7,8 +7,7 @@ import { generateUUID } from "@/utils/uuid";
|
|
|
7
7
|
import type { Banner } from "../kernel/messages";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
* When set to a non-null value, shows a modal with the error details.
|
|
10
|
+
* Full startup diagnostics, retained until a new connection attempt or readiness.
|
|
12
11
|
*/
|
|
13
12
|
export const kernelStartupErrorAtom = atom<string | null>(null);
|
|
14
13
|
|
|
@@ -370,6 +370,9 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
370
370
|
addPackage = throwNotImplemented;
|
|
371
371
|
removePackage = throwNotImplemented;
|
|
372
372
|
getPackageList = throwNotImplemented;
|
|
373
|
+
getSandbox = throwNotImplemented;
|
|
374
|
+
updateManifest = throwNotImplemented;
|
|
375
|
+
syncSandbox = throwNotImplemented;
|
|
373
376
|
getDependencyTree = throwNotImplemented;
|
|
374
377
|
getRecentFiles = throwNotImplemented;
|
|
375
378
|
getWorkspaceFiles = throwNotImplemented;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { atom } from "jotai";
|
|
3
|
+
import { kernelStartupErrorAtom } from "@/core/errors/state";
|
|
4
|
+
import { sandboxAtom, sandboxSyncAtom } from "@/core/packages/sandbox-state";
|
|
5
|
+
import { WebSocketClosedReason, WebSocketState } from "@/core/websocket/types";
|
|
6
|
+
import { connectionAtom } from "./connection";
|
|
7
|
+
|
|
8
|
+
export const connectionNoticeAtom = atom((get) => {
|
|
9
|
+
const connection = get(connectionAtom);
|
|
10
|
+
const backend = get(sandboxAtom)?.backend;
|
|
11
|
+
const sandbox = Boolean(backend);
|
|
12
|
+
const sync = get(sandboxSyncAtom);
|
|
13
|
+
if (
|
|
14
|
+
sandbox &&
|
|
15
|
+
(sync.pending || sync.error) &&
|
|
16
|
+
connection.state === WebSocketState.OPEN
|
|
17
|
+
) {
|
|
18
|
+
return {
|
|
19
|
+
sandbox,
|
|
20
|
+
title: sync.pending ? "Syncing sandbox…" : "Sandbox sync failed",
|
|
21
|
+
description: sync.pending
|
|
22
|
+
? "Applying the saved manifest to this notebook’s environment."
|
|
23
|
+
: "Review the error details, then try again.",
|
|
24
|
+
pending: sync.pending,
|
|
25
|
+
error: sync.error,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (connection.state === WebSocketState.CONNECTING) {
|
|
29
|
+
const messages = {
|
|
30
|
+
"preparing-environment": {
|
|
31
|
+
title: backend
|
|
32
|
+
? `Preparing ${backend} sandbox…`
|
|
33
|
+
: "Preparing environment…",
|
|
34
|
+
description:
|
|
35
|
+
"Getting this notebook’s Python environment ready. The first start can take a few minutes.",
|
|
36
|
+
},
|
|
37
|
+
"starting-kernel": {
|
|
38
|
+
title: "Starting notebook…",
|
|
39
|
+
description:
|
|
40
|
+
"The environment is prepared. Waiting for the Python kernel to connect to this notebook.",
|
|
41
|
+
},
|
|
42
|
+
reconnecting: {
|
|
43
|
+
title: "Reconnecting…",
|
|
44
|
+
description:
|
|
45
|
+
"The connection was interrupted. Your notebook and existing outputs stay visible while the kernel reconnects.",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
sandbox,
|
|
50
|
+
...(connection.phase
|
|
51
|
+
? messages[connection.phase]
|
|
52
|
+
: {
|
|
53
|
+
title: "Connecting…",
|
|
54
|
+
description: "Waiting for the notebook’s kernel to connect.",
|
|
55
|
+
}),
|
|
56
|
+
pending: true,
|
|
57
|
+
error: null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (connection.state === WebSocketState.CLOSED) {
|
|
61
|
+
const startupFailed =
|
|
62
|
+
connection.code === WebSocketClosedReason.KERNEL_STARTUP_ERROR;
|
|
63
|
+
const environmentFailed =
|
|
64
|
+
startupFailed && connection.phase === "preparing-environment";
|
|
65
|
+
return {
|
|
66
|
+
sandbox,
|
|
67
|
+
title: environmentFailed
|
|
68
|
+
? "Sandbox setup failed"
|
|
69
|
+
: startupFailed
|
|
70
|
+
? "Kernel failed to start"
|
|
71
|
+
: connection.reason,
|
|
72
|
+
description: environmentFailed
|
|
73
|
+
? "The notebook can’t run yet."
|
|
74
|
+
: startupFailed
|
|
75
|
+
? "The Python kernel could not start. Check the error details, then try again."
|
|
76
|
+
: "The connection to the notebook’s kernel was lost.",
|
|
77
|
+
pending: false,
|
|
78
|
+
error: startupFailed
|
|
79
|
+
? (get(kernelStartupErrorAtom) ?? connection.reason)
|
|
80
|
+
: null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
});
|
|
@@ -93,6 +93,9 @@ const ACTIONS: Record<keyof AllRequests, Action> = {
|
|
|
93
93
|
saveUserConfig: "throwError",
|
|
94
94
|
sendShutdown: "throwError",
|
|
95
95
|
getPackageList: "throwError",
|
|
96
|
+
getSandbox: "serverOnly",
|
|
97
|
+
updateManifest: "serverOnly",
|
|
98
|
+
syncSandbox: "serverOnly",
|
|
96
99
|
getDependencyTree: "throwError",
|
|
97
100
|
addPackage: "throwError",
|
|
98
101
|
removePackage: "throwError",
|
|
@@ -532,6 +532,18 @@ export function createNetworkRequests(): EditRequests & RunRequests {
|
|
|
532
532
|
})
|
|
533
533
|
.then(handleResponseReturnNull);
|
|
534
534
|
},
|
|
535
|
+
getSandbox: (request) =>
|
|
536
|
+
getClient()
|
|
537
|
+
.POST("/api/packages/sandbox", { body: request, params: getParams() })
|
|
538
|
+
.then(handleResponse),
|
|
539
|
+
updateManifest: (request) =>
|
|
540
|
+
getClient()
|
|
541
|
+
.POST("/api/packages/manifest", { body: request, params: getParams() })
|
|
542
|
+
.then(handleResponse),
|
|
543
|
+
syncSandbox: (request) =>
|
|
544
|
+
getClient()
|
|
545
|
+
.POST("/api/packages/sync", { body: request, params: getParams() })
|
|
546
|
+
.then(handleResponse),
|
|
535
547
|
addPackage: (request) => {
|
|
536
548
|
return getClient()
|
|
537
549
|
.POST("/api/packages/add", {
|
|
@@ -98,6 +98,9 @@ export function createStaticRequests(): EditRequests & RunRequests {
|
|
|
98
98
|
addPackage: throwNotInEditMode,
|
|
99
99
|
removePackage: throwNotInEditMode,
|
|
100
100
|
getPackageList: throwNotInEditMode,
|
|
101
|
+
getSandbox: throwNotInEditMode,
|
|
102
|
+
updateManifest: throwNotInEditMode,
|
|
103
|
+
syncSandbox: throwNotInEditMode,
|
|
101
104
|
getDependencyTree: throwNotInEditMode,
|
|
102
105
|
listSecretKeys: throwNotInEditMode,
|
|
103
106
|
writeSecret: throwNotInEditMode,
|
|
@@ -79,6 +79,9 @@ export function createErrorToastingRequests(
|
|
|
79
79
|
addPackage: "Failed to add package",
|
|
80
80
|
removePackage: "Failed to remove package",
|
|
81
81
|
getPackageList: "Failed to get package list",
|
|
82
|
+
getSandbox: "",
|
|
83
|
+
updateManifest: "",
|
|
84
|
+
syncSandbox: "",
|
|
82
85
|
getDependencyTree: "Failed to get dependency tree",
|
|
83
86
|
listSecretKeys: "Failed to fetch secrets",
|
|
84
87
|
writeSecret: "Failed to write secret",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { withPackageInvalidation } from "../packages/package-data";
|
|
2
3
|
import { getRuntimeManager } from "../runtime/config";
|
|
3
4
|
import { isStaticNotebook } from "../static/static-state";
|
|
4
5
|
import { PyodideBridge } from "../wasm/bridge";
|
|
@@ -20,5 +21,5 @@ export function resolveRequestClient(): EditRequests & RunRequests {
|
|
|
20
21
|
getRuntimeManager(),
|
|
21
22
|
);
|
|
22
23
|
}
|
|
23
|
-
return createErrorToastingRequests(base);
|
|
24
|
+
return withPackageInvalidation(createErrorToastingRequests(base));
|
|
24
25
|
}
|
|
@@ -69,6 +69,10 @@ export type ListPackagesResponse = schemas["ListPackagesResponse"];
|
|
|
69
69
|
export type DependencyTreeResponse = schemas["DependencyTreeResponse"];
|
|
70
70
|
export type DependencyTreeNode = schemas["DependencyTreeNode"];
|
|
71
71
|
|
|
72
|
+
export type SandboxRequest = schemas["SandboxRequest"];
|
|
73
|
+
export type SandboxResponse = schemas["SandboxResponse"];
|
|
74
|
+
export type UpdateManifestRequest = schemas["UpdateManifestRequest"];
|
|
75
|
+
export type SyncSandboxResponse = schemas["SyncSandboxResponse"];
|
|
72
76
|
export type PackageOperationResponse = schemas["PackageOperationResponse"];
|
|
73
77
|
export type InstantiateNotebookRequest = schemas["InstantiateNotebookRequest"];
|
|
74
78
|
export type MarimoConfig = schemas["MarimoConfig"];
|
|
@@ -246,6 +250,9 @@ export interface EditRequests {
|
|
|
246
250
|
autoExportAsMarkdown: (request: AutoExportAsMarkdownRequest) => Promise<null>;
|
|
247
251
|
autoExportAsIPYNB: (request: AutoExportAsIPYNBRequest) => Promise<null>;
|
|
248
252
|
updateCellOutputs: (request: UpdateCellOutputsRequest) => Promise<null>;
|
|
253
|
+
getSandbox: (request: SandboxRequest) => Promise<SandboxResponse>;
|
|
254
|
+
updateManifest: (request: UpdateManifestRequest) => Promise<SandboxResponse>;
|
|
255
|
+
syncSandbox: (request: SandboxRequest) => Promise<SyncSandboxResponse>;
|
|
249
256
|
// Package requests
|
|
250
257
|
getPackageList: () => Promise<ListPackagesResponse>;
|
|
251
258
|
getDependencyTree: () => Promise<DependencyTreeResponse>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { act, renderHook } from "@testing-library/react";
|
|
3
|
+
import { createStore, Provider } from "jotai";
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { MockRequestClient } from "@/__mocks__/requests";
|
|
6
|
+
import { toast } from "@/components/ui/use-toast";
|
|
7
|
+
import { requestClientAtom } from "@/core/network/requests";
|
|
8
|
+
import type { PackageOperationResponse } from "@/core/network/types";
|
|
9
|
+
import { usePackageAction } from "../usePackageAction";
|
|
10
|
+
|
|
11
|
+
vi.mock("@/components/ui/use-toast", () => ({ toast: vi.fn() }));
|
|
12
|
+
|
|
13
|
+
function setup(action: "upgrade" | "remove") {
|
|
14
|
+
const client = MockRequestClient.create();
|
|
15
|
+
const store = createStore();
|
|
16
|
+
store.set(requestClientAtom, client);
|
|
17
|
+
return {
|
|
18
|
+
request: vi.mocked(
|
|
19
|
+
action === "upgrade" ? client.addPackage : client.removePackage,
|
|
20
|
+
),
|
|
21
|
+
...renderHook(
|
|
22
|
+
() =>
|
|
23
|
+
usePackageAction(action, "numpy", [{ kind: "group", value: "dev" }]),
|
|
24
|
+
{
|
|
25
|
+
wrapper: ({ children }) => (
|
|
26
|
+
<Provider store={store}>{children}</Provider>
|
|
27
|
+
),
|
|
28
|
+
},
|
|
29
|
+
),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("usePackageAction", () => {
|
|
34
|
+
beforeEach(() => vi.clearAllMocks());
|
|
35
|
+
|
|
36
|
+
it.each([
|
|
37
|
+
{ action: "upgrade", title: "Package upgraded", upgrade: true },
|
|
38
|
+
{ action: "remove", title: "Package removed" },
|
|
39
|
+
] as const)(
|
|
40
|
+
"preserves dependency groups and prevents duplicate $action requests",
|
|
41
|
+
async ({ action, title, ...options }) => {
|
|
42
|
+
const { result, request } = setup(action);
|
|
43
|
+
const { promise, resolve } =
|
|
44
|
+
Promise.withResolvers<PackageOperationResponse>();
|
|
45
|
+
request.mockReturnValue(promise);
|
|
46
|
+
let pending: Promise<void>;
|
|
47
|
+
act(() => {
|
|
48
|
+
pending = result.current.run();
|
|
49
|
+
});
|
|
50
|
+
expect(result.current.loading).toBe(true);
|
|
51
|
+
await act(async () => {
|
|
52
|
+
await result.current.run();
|
|
53
|
+
});
|
|
54
|
+
expect(request).toHaveBeenCalledExactlyOnceWith({
|
|
55
|
+
package: "numpy",
|
|
56
|
+
group: "dev",
|
|
57
|
+
...options,
|
|
58
|
+
});
|
|
59
|
+
await act(async () => {
|
|
60
|
+
resolve({ success: true });
|
|
61
|
+
await pending;
|
|
62
|
+
});
|
|
63
|
+
expect(result.current.loading).toBe(false);
|
|
64
|
+
expect(toast).toHaveBeenCalledExactlyOnceWith(
|
|
65
|
+
expect.objectContaining({ title }),
|
|
66
|
+
);
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
it("reports saved changes requiring a restart without claiming success", async () => {
|
|
71
|
+
const { result, request } = setup("upgrade");
|
|
72
|
+
request.mockResolvedValue({ success: false, restartRequired: true });
|
|
73
|
+
await act(async () => {
|
|
74
|
+
await result.current.run();
|
|
75
|
+
});
|
|
76
|
+
expect(toast).toHaveBeenCalledExactlyOnceWith(
|
|
77
|
+
expect.objectContaining({
|
|
78
|
+
title: "Changes saved — restart required",
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it.each([
|
|
84
|
+
{
|
|
85
|
+
response: { success: false, error: "resolver failed" },
|
|
86
|
+
description: "resolver failed",
|
|
87
|
+
},
|
|
88
|
+
{ response: { success: false }, description: expect.any(String) },
|
|
89
|
+
])(
|
|
90
|
+
"reports failed responses as errors: $response",
|
|
91
|
+
async ({ response, description }) => {
|
|
92
|
+
const { result, request } = setup("remove");
|
|
93
|
+
request.mockResolvedValue(response);
|
|
94
|
+
await act(async () => {
|
|
95
|
+
await result.current.run();
|
|
96
|
+
});
|
|
97
|
+
expect(toast).toHaveBeenCalledExactlyOnceWith(
|
|
98
|
+
expect.objectContaining({
|
|
99
|
+
title: "Failed to remove package",
|
|
100
|
+
description,
|
|
101
|
+
variant: "danger",
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
it("reports request errors and allows a retry", async () => {
|
|
108
|
+
const { result, request } = setup("remove");
|
|
109
|
+
request.mockRejectedValueOnce(new Error("offline"));
|
|
110
|
+
await act(async () => {
|
|
111
|
+
await result.current.run();
|
|
112
|
+
});
|
|
113
|
+
expect(toast).toHaveBeenCalledExactlyOnceWith(
|
|
114
|
+
expect.objectContaining({
|
|
115
|
+
title: "Failed to remove package",
|
|
116
|
+
description: "offline",
|
|
117
|
+
variant: "danger",
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
expect(result.current.loading).toBe(false);
|
|
121
|
+
request.mockResolvedValue({ success: true });
|
|
122
|
+
await act(async () => {
|
|
123
|
+
await result.current.run();
|
|
124
|
+
});
|
|
125
|
+
expect(toast).toHaveBeenLastCalledWith(
|
|
126
|
+
expect.objectContaining({ title: "Package removed" }),
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
3
|
+
import { Provider } from "jotai";
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { MockRequestClient } from "@/__mocks__/requests";
|
|
6
|
+
import { kernelStartupErrorAtom } from "@/core/errors/state";
|
|
7
|
+
import { connectionAtom } from "@/core/network/connection";
|
|
8
|
+
import { requestClientAtom } from "@/core/network/requests";
|
|
9
|
+
import type {
|
|
10
|
+
SandboxResponse,
|
|
11
|
+
SyncSandboxResponse,
|
|
12
|
+
} from "@/core/network/types";
|
|
13
|
+
import { filenameAtom } from "@/core/saving/file-state";
|
|
14
|
+
import { store } from "@/core/state/jotai";
|
|
15
|
+
import { WebSocketState } from "@/core/websocket/types";
|
|
16
|
+
import { HTTPError } from "@/utils/errors";
|
|
17
|
+
import { packageDataVersionAtom } from "../package-data";
|
|
18
|
+
import {
|
|
19
|
+
sandboxActionsAtom,
|
|
20
|
+
sandboxAtom,
|
|
21
|
+
sandboxSyncAtom,
|
|
22
|
+
} from "../sandbox-state";
|
|
23
|
+
import { useSandboxController } from "../useSandboxController";
|
|
24
|
+
|
|
25
|
+
vi.mock("../toast-components", () => ({
|
|
26
|
+
showPackageRestartToast: vi.fn(),
|
|
27
|
+
showSandboxSyncToast: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
const manifest = 'dependencies = ["numpy"]';
|
|
31
|
+
const sandbox: SandboxResponse = {
|
|
32
|
+
backend: "uv",
|
|
33
|
+
manifest,
|
|
34
|
+
filename: "notebook.py",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
store.set(sandboxAtom, null);
|
|
39
|
+
store.set(sandboxSyncAtom, { pending: false, error: null });
|
|
40
|
+
store.set(sandboxActionsAtom, null);
|
|
41
|
+
store.set(connectionAtom, { state: WebSocketState.OPEN });
|
|
42
|
+
store.set(kernelStartupErrorAtom, null);
|
|
43
|
+
store.set(filenameAtom, "notebook.py");
|
|
44
|
+
store.set(packageDataVersionAtom, 0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function setup() {
|
|
48
|
+
const client = MockRequestClient.create({
|
|
49
|
+
getSandbox: vi.fn().mockResolvedValue(sandbox),
|
|
50
|
+
updateManifest: vi.fn(async ({ contents }) => ({
|
|
51
|
+
...sandbox,
|
|
52
|
+
manifest: contents,
|
|
53
|
+
})),
|
|
54
|
+
syncSandbox: vi.fn().mockResolvedValue({ success: true, reconnect: false }),
|
|
55
|
+
});
|
|
56
|
+
store.set(requestClientAtom, client);
|
|
57
|
+
const reconnect = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
|
58
|
+
return {
|
|
59
|
+
client,
|
|
60
|
+
reconnect,
|
|
61
|
+
...renderHook(() => useSandboxController(reconnect), {
|
|
62
|
+
wrapper: ({ children }) => <Provider store={store}>{children}</Provider>,
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("useSandboxController", () => {
|
|
68
|
+
it("keeps a conflicting draft across closing and only discards it on explicit reload", async () => {
|
|
69
|
+
const { result, client } = setup();
|
|
70
|
+
vi.mocked(client.updateManifest).mockRejectedValue(
|
|
71
|
+
new HTTPError(409, "Conflict", { detail: "Manifest changed on disk" }),
|
|
72
|
+
);
|
|
73
|
+
await act(async () => {
|
|
74
|
+
await store.get(sandboxActionsAtom)?.editManifest();
|
|
75
|
+
});
|
|
76
|
+
act(() => result.current.setDraft("dependencies = []"));
|
|
77
|
+
await act(async () => {
|
|
78
|
+
await result.current.saveAndSync();
|
|
79
|
+
});
|
|
80
|
+
expect(client.updateManifest).toHaveBeenCalledExactlyOnceWith({
|
|
81
|
+
fileKey: "notebook.py",
|
|
82
|
+
previous: manifest,
|
|
83
|
+
contents: "dependencies = []",
|
|
84
|
+
});
|
|
85
|
+
expect(result.current).toMatchObject({
|
|
86
|
+
open: true,
|
|
87
|
+
draft: "dependencies = []",
|
|
88
|
+
dirty: true,
|
|
89
|
+
conflict: true,
|
|
90
|
+
diagnostic: "Manifest changed on disk",
|
|
91
|
+
pending: false,
|
|
92
|
+
});
|
|
93
|
+
expect(client.syncSandbox).not.toHaveBeenCalled();
|
|
94
|
+
act(() => result.current.setOpen(false));
|
|
95
|
+
await act(async () => {
|
|
96
|
+
await store.get(sandboxActionsAtom)?.editManifest();
|
|
97
|
+
});
|
|
98
|
+
expect(result.current.draft).toBe("dependencies = []");
|
|
99
|
+
await act(async () => {
|
|
100
|
+
await result.current.loadManifest();
|
|
101
|
+
});
|
|
102
|
+
expect(result.current).toMatchObject({
|
|
103
|
+
draft: manifest,
|
|
104
|
+
dirty: false,
|
|
105
|
+
conflict: false,
|
|
106
|
+
diagnostic: null,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("shares sync progress across callers and refreshes packages without reconnecting", async () => {
|
|
111
|
+
const { client, reconnect } = setup();
|
|
112
|
+
const { promise, resolve } = Promise.withResolvers<SyncSandboxResponse>();
|
|
113
|
+
vi.mocked(client.syncSandbox).mockReturnValue(promise);
|
|
114
|
+
const actions = store.get(sandboxActionsAtom)!;
|
|
115
|
+
let pending: Promise<boolean>;
|
|
116
|
+
act(() => {
|
|
117
|
+
pending = actions.sync();
|
|
118
|
+
});
|
|
119
|
+
expect(store.get(sandboxSyncAtom)).toEqual({ pending: true, error: null });
|
|
120
|
+
await act(async () => {
|
|
121
|
+
expect(await actions.sync()).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
expect(client.syncSandbox).toHaveBeenCalledTimes(1);
|
|
124
|
+
await act(async () => {
|
|
125
|
+
resolve({ success: true, reconnect: false });
|
|
126
|
+
expect(await pending).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
expect(store.get(sandboxSyncAtom)).toEqual({ pending: false, error: null });
|
|
129
|
+
expect(store.get(packageDataVersionAtom)).toBe(1);
|
|
130
|
+
expect(reconnect).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("reports sync failure and clears it when retrying", async () => {
|
|
134
|
+
const { client } = setup();
|
|
135
|
+
const actions = store.get(sandboxActionsAtom)!;
|
|
136
|
+
vi.mocked(client.syncSandbox).mockRejectedValueOnce(new Error("offline"));
|
|
137
|
+
await act(async () => {
|
|
138
|
+
expect(await actions.sync()).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
expect(store.get(sandboxSyncAtom)).toEqual({
|
|
141
|
+
pending: false,
|
|
142
|
+
error: "offline",
|
|
143
|
+
});
|
|
144
|
+
await act(async () => {
|
|
145
|
+
expect(await actions.sync()).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
expect(store.get(sandboxSyncAtom)).toEqual({ pending: false, error: null });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("ignores stale metadata after a filename change and unregisters actions on unmount", async () => {
|
|
151
|
+
const { client, unmount } = setup();
|
|
152
|
+
await waitFor(() => expect(store.get(sandboxAtom)).toEqual(sandbox));
|
|
153
|
+
const { promise, resolve } = Promise.withResolvers<SandboxResponse>();
|
|
154
|
+
vi.mocked(client.getSandbox).mockReturnValueOnce(promise);
|
|
155
|
+
act(() => store.set(filenameAtom, "old.py"));
|
|
156
|
+
const current = { ...sandbox, filename: "current.py" };
|
|
157
|
+
vi.mocked(client.getSandbox).mockResolvedValue(current);
|
|
158
|
+
act(() => store.set(filenameAtom, "current.py"));
|
|
159
|
+
await waitFor(() => expect(store.get(sandboxAtom)).toEqual(current));
|
|
160
|
+
await act(async () => resolve({ ...sandbox, filename: "old.py" }));
|
|
161
|
+
expect(store.get(sandboxAtom)).toEqual(current);
|
|
162
|
+
unmount();
|
|
163
|
+
expect(store.get(sandboxActionsAtom)).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
});
|