Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions frontend/src/core/islands/__tests__/main.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof MainModule> {
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<string, unknown>)[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<typeof MainModule.canReplaceApp>().toEqualTypeOf<
() => boolean
>();
expectTypeOf<typeof MainModule.initialize>().toEqualTypeOf<
() => Promise<void>
>();
expectTypeOf<typeof MainModule.stopApp>().toEqualTypeOf<
(appId?: string) => Promise<void>
>();
});
});

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 = `<marimo-island data-app-id="app-1"></marimo-island>`;
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 = `<marimo-island data-app-id="app-1"></marimo-island>`;
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 = `<marimo-island></marimo-island>`;
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 = `<marimo-island></marimo-island>`;

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 = `<marimo-island></marimo-island>`;
mockInitializeIslands.mockRejectedValueOnce(new Error("bootstrap failed"));

await expect(mod.initialize()).rejects.toThrow("bootstrap failed");
await mod.initialize();

expect(mockInitializeIslands).toHaveBeenCalledTimes(2);
expect(mockInitializeApps).toHaveBeenCalledOnce();
});
});
38 changes: 36 additions & 2 deletions frontend/src/core/islands/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,31 @@ import { parseMarimoIslandApps } from "./parse";
const bridge = getGlobalBridge();
let bootstrapPromise: Promise<void> | 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;
Expand All @@ -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<void> {
const islands = document.querySelectorAll<HTMLElement>(
Expand All @@ -49,7 +77,13 @@ export async function initialize(): Promise<void> {
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<void> {
await bridge.stopSession(appId);
}
Expand Down
Loading