diff --git a/frontend/src/core/islands/__tests__/main.test.ts b/frontend/src/core/islands/__tests__/main.test.ts new file mode 100644 index 00000000000..0c84f9a0633 --- /dev/null +++ b/frontend/src/core/islands/__tests__/main.test.ts @@ -0,0 +1,176 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest"; +import type * as MainModule from "../main"; + +// `main.ts` is the public islands entry point (see islands/vite.config.mts), +// so this suite snapshots its export surface + signatures and covers each +// entry point. The real bridge/bootstrap spin up a Web Worker and the plugin +// runtime, so we mock them. +const mockInitializeApps = vi.fn(() => Promise.resolve()); +const mockStopSession = vi.fn((_appId?: string) => Promise.resolve()); +const mockInitializeIslands = vi.fn(() => Promise.resolve()); +const mockParseMarimoIslandApps = vi.fn(() => [] as Array<{ id: string }>); + +vi.mock("@/core/islands/bridge", () => ({ + getGlobalBridge: () => ({ + initializeApps: mockInitializeApps, + stopSession: mockStopSession, + }), +})); + +vi.mock("@/core/islands/bootstrap", () => ({ + initializeIslands: mockInitializeIslands, +})); + +vi.mock("@/core/islands/parse", () => ({ + parseMarimoIslandApps: mockParseMarimoIslandApps, +})); + +/** + * Re-import a fresh copy of `main.ts` so the module-level `bootstrapPromise` + * memoization does not leak between tests. The module auto-runs `initialize()` + * on load, so we clear the DOM first to keep that a no-op. + */ +async function importMain(): Promise { + vi.resetModules(); + return import("../main"); +} + +beforeEach(() => { + document.body.replaceChildren(); + mockInitializeApps.mockClear(); + mockStopSession.mockClear(); + mockInitializeIslands.mockClear(); + mockParseMarimoIslandApps.mockReset(); + mockParseMarimoIslandApps.mockReturnValue([]); +}); + +describe("islands public API surface", () => { + it("exposes a stable set of exports and signatures", async () => { + const mod = await importMain(); + const surface = Object.fromEntries( + Object.keys(mod) + .toSorted() + .map((key) => { + const value = (mod as Record)[key]; + return [ + key, + typeof value === "function" + ? `function(arity ${value.length})` + : typeof value, + ]; + }), + ); + + expect(surface).toMatchInlineSnapshot(` + { + "canReplaceApp": "function(arity 0)", + "initialize": "function(arity 0)", + "stopApp": "function(arity 1)", + } + `); + }); + + it("matches the declared TypeScript signatures", () => { + expectTypeOf().toEqualTypeOf< + () => boolean + >(); + expectTypeOf().toEqualTypeOf< + () => Promise + >(); + expectTypeOf().toEqualTypeOf< + (appId?: string) => Promise + >(); + }); +}); + +describe("canReplaceApp", () => { + it("returns false when the document has no island element", async () => { + const mod = await importMain(); + + expect(mod.canReplaceApp()).toBe(false); + // Short-circuits on the DOM guard before parsing. + expect(mockParseMarimoIslandApps).not.toHaveBeenCalled(); + }); + + it("returns true for exactly one app without materializing the DOM", async () => { + const mod = await importMain(); + document.body.innerHTML = ``; + mockParseMarimoIslandApps.mockReturnValue([{ id: "app-1" }]); + + expect(mod.canReplaceApp()).toBe(true); + // A probe must not mutate island attributes. + expect(mockParseMarimoIslandApps).toHaveBeenCalledWith(document, { + materialize: false, + }); + }); + + it("returns false when more than one app is present", async () => { + const mod = await importMain(); + document.body.innerHTML = ``; + mockParseMarimoIslandApps.mockReturnValue([ + { id: "app-1" }, + { id: "app-2" }, + ]); + + expect(mod.canReplaceApp()).toBe(false); + }); + + it("returns false when the island parses into zero apps", async () => { + const mod = await importMain(); + document.body.innerHTML = ``; + mockParseMarimoIslandApps.mockReturnValue([]); + + expect(mod.canReplaceApp()).toBe(false); + }); +}); + +describe("stopApp", () => { + it("forwards the requested app id to the bridge", async () => { + const mod = await importMain(); + + await mod.stopApp("app-7"); + expect(mockStopSession).toHaveBeenCalledWith("app-7"); + + await mod.stopApp(); + expect(mockStopSession).toHaveBeenLastCalledWith(undefined); + }); +}); + +describe("initialize", () => { + it("skips bootstrap and app start when no islands are present", async () => { + const mod = await importMain(); + + await mod.initialize(); + + expect(mockInitializeIslands).not.toHaveBeenCalled(); + expect(mockInitializeApps).not.toHaveBeenCalled(); + }); + + it("bootstraps once, marks islands, and starts apps on each call", async () => { + const mod = await importMain(); + document.body.innerHTML = ``; + + await mod.initialize(); + await mod.initialize(); + + expect(mockInitializeIslands).toHaveBeenCalledOnce(); + expect(mockInitializeApps).toHaveBeenCalledTimes(2); + expect( + document.querySelector("marimo-island")?.classList.contains("marimo"), + ).toBe(true); + }); + + it("clears the memoized bootstrap when it fails so it can be retried", async () => { + const mod = await importMain(); + document.body.innerHTML = ``; + mockInitializeIslands.mockRejectedValueOnce(new Error("bootstrap failed")); + + await expect(mod.initialize()).rejects.toThrow("bootstrap failed"); + await mod.initialize(); + + expect(mockInitializeIslands).toHaveBeenCalledTimes(2); + expect(mockInitializeApps).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/core/islands/main.ts b/frontend/src/core/islands/main.ts index e2721cad753..386442b0e73 100644 --- a/frontend/src/core/islands/main.ts +++ b/frontend/src/core/islands/main.ts @@ -20,7 +20,31 @@ import { parseMarimoIslandApps } from "./parse"; const bridge = getGlobalBridge(); let bootstrapPromise: Promise | undefined; -/** Returns whether the current document can replace its app in this worker. */ +/** + * Public entry point for the islands bundle. + * + * Islands auto-initialize on load. Hosts that keep the browser realm alive + * across client-side navigation (retaining this worker and its Pyodide + * environment) can drive page transitions with: + * + * ```ts + * if (canReplaceApp()) { + * await stopApp(); + * updatePage(); + * await initialize(); + * } + * ``` + * + * The retained lifecycle (`canReplaceApp`/`stopApp`) only applies to pages + * whose islands resolve to a single app; multi-app pages are not replaceable. + */ + +/** + * Returns whether the current document can replace its app in this worker. + * + * True only when the document contains exactly one replaceable app. This is a + * read-only probe: it does not materialize island payloads or mutate the DOM. + */ export function canReplaceApp(): boolean { if (!document.querySelector(ISLAND_TAG_NAMES.ISLAND)) { return false; @@ -30,6 +54,10 @@ export function canReplaceApp(): boolean { /** * Mounts marimo custom elements and starts the apps in the current document. + * + * Safe to call repeatedly, including after client-side navigation: the worker + * bootstrap is memoized (and cleared if it fails, so a later call retries), + * while app discovery re-runs on every call to pick up the current DOM. */ export async function initialize(): Promise { const islands = document.querySelectorAll( @@ -49,7 +77,13 @@ export async function initialize(): Promise { await bridge.initializeApps(); } -/** Stops the matching active app session. */ +/** + * Stops the matching active app session. + * + * With no `appId`, stops the current retained single-app session; with an + * `appId`, stops it only if it matches the active app. No-op when there is no + * retained session to stop. + */ export async function stopApp(appId?: string): Promise { await bridge.stopSession(appId); }