/* Copyright 2026 Marimo. All rights reserved. */ import type * as api from "@marimo-team/marimo-api"; /* oxlint-disable typescript/no-explicit-any */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { Mocks } from "@/__mocks__/common"; import { cellId } from "@/__tests__/branded"; import { parseOutline } from "@/core/dom/outline"; import { MultiColumn, visibleForTesting } from "@/utils/id-tree"; import { invariant } from "@/utils/invariant"; import { Logger } from "@/utils/Logger"; import { SETUP_CELL_ID } from "../ids"; import { notebookStateFromSession } from "../session"; // Mock dependencies vi.mock("@/utils/Logger", () => ({ Logger: Mocks.quietLogger() })); vi.mock("@/core/dom/outline", () => ({ parseOutline: vi.fn(), })); type SessionCell = api.Session["NotebookSessionV1"]["cells"][0]; type NotebookCell = api.Notebook["NotebookV1"]["cells"][0]; // Test constants const CELL_1 = cellId("cell-1"); describe("notebookStateFromSession", () => { beforeEach(() => { vi.clearAllMocks(); visibleForTesting.reset(); }); // Test data factories const createSessionCell = ( id: string, outputs: SessionCell["outputs"] = [], console: SessionCell["console"] = [], code_hash: string | null = null, ): SessionCell => ({ id, code_hash, outputs, console, }); const createNotebookCell = ( id: string, code: string | null = null, name: string | null = null, config: NotebookCell["config"] | null = null, code_hash: string | null = null, ): NotebookCell => ({ id, code, code_hash: code_hash, name, config: { column: config?.column ?? null, disabled: config?.disabled ?? null, hide_code: config?.hide_code ?? null, }, }); const createSession = ( cells: SessionCell[], ): api.Session["NotebookSessionV1"] => ({ version: "1", metadata: { marimo_version: "1", script_metadata_hash: null }, cells, }); const createNotebook = ( cells: NotebookCell[], ): api.Notebook["NotebookV1"] => ({ version: "1", metadata: { marimo_version: "1" }, cells, }); describe("validation", () => { it("logs error for both session and notebook null/undefined", () => { const result = notebookStateFromSession(null, null); expect(result).toBeNull(); }); it("logs error when session and notebook have different cells", () => { const session = createSession([createSessionCell("cell-1")]); const notebook = createNotebook([createNotebookCell("cell-2")]); const result = notebookStateFromSession(session, notebook); expect(Logger.warn).toHaveBeenCalledWith( "Session and notebook have different cells, attempted merge.", ); // Should have the same cell structure as notebook expect(result).not.toBeNull(); invariant(result, "result is null"); expect(result.cellIds.inOrderIds).toEqual( MultiColumn.from([["cell-2"]]).inOrderIds, ); }); }); describe("null/undefined inputs", () => { it("returns null when both session and notebook are null", () => { const result = notebookStateFromSession(null, null); expect(result).toBeNull(); }); it("returns null when both session and notebook are undefined", () => { const result = notebookStateFromSession(undefined, undefined); expect(result).toBeNull(); }); it("returns null when session is null and notebook is undefined", () => { const result = notebookStateFromSession(null, undefined); expect(result).toBeNull(); }); }); describe("session only scenarios", () => { it("creates state from session with single cell", () => { const session = createSession([createSessionCell("cell-1")]); const result = notebookStateFromSession(session, null); expect(result).not.toBeNull(); invariant(result, "result is null"); expect(result.cellIds.inOrderIds).toEqual( MultiColumn.from([[CELL_1]]).inOrderIds, ); expect(result.cellData[CELL_1].code).toBe(""); expect(result.cellRuntime[CELL_1]).toBeDefined(); }); it("creates state from session with multiple cells", () => { const session = createSession([ createSessionCell("cell-1"), createSessionCell("cell-2"), ]); const result = notebookStateFromSession(session, null); expect(result).not.toBeNull(); invariant(result, "result is null"); expect(result.cellIds.inOrderIds).toEqual( MultiColumn.from([["cell-1", "cell-2"]]).inOrderIds, ); expect(Object.keys((result as any).cellData)).toEqual([ "cell-1", "cell-2", ]); expect(Object.keys((result as any).cellRuntime)).toEqual([ "cell-1", "cell-2", ]); }); it("handles error output in session cell", () => { const errorOutput = { type: "error" as const, ename: "ValueError", evalue: "Something went wrong", traceback: ["Traceback line 1"], }; const session = createSession([ createSessionCell("cell-1", [errorOutput]), ]); const result = notebookStateFromSession(session, null); expect(result).not.toBeNull(); invariant(result, "result is null"); expect(result.cellRuntime[CELL_1].output).toEqual({ channel: "marimo-error", data: [ { type: "unknown", msg: "Something went wrong", }, ], mimetype: "application/vnd.marimo+error", timestamp: 0, }); }); it("handles data output in session cell", () => { const dataOutput = { type: "data" as const, data: { "text/plain": "Hello World", }, }; const session = createSession([ createSessionCell("cell-1", [dataOutput]), ]); const result = notebookStateFromSession(session, null); expect(result).not.toBeNull(); invariant(result, "result is null"); expect(result.cellRuntime[CELL_1].output).toEqual({ channel: "output", data: "Hello World", mimetype: "text/plain", timestamp: 0, }); }); it("handles console outputs in session cell", () => { const consoleOutputs = [ { type: "stream", name: "stdout", text: "Hello stdout", mimetype: "text/plain", } as const, { type: "stream", name: "stderr", text: "Hello stderr", mimetype: "text/plain", } as const, ]; const session = createSession([ createSessionCell("cell-1", [], consoleOutputs), ]); const result = notebookStateFromSession(session, null); expect(result).not.toBeNull(); invariant(result, "result is null"); expect(result.cellRuntime[CELL_1].consoleOutputs).toEqual([ { channel: "stdout", data: "Hello stdout", mimetype: "text/plain", timestamp: 0, }, { channel: "stderr", data: "Hello stderr", mimetype: "text/plain", timestamp: 0, }, ]); }); it("calls parseOutline when output exists", () => { const mockOutline = { items: [] }; vi.mocked(parseOutline).mockReturnValue(mockOutline); const dataOutput = { type: "data" as const, data: { "text/html": "
HTML
", }, }; const session = createSession([ createSessionCell("cell-1", [dataOutput]), ]); const result = notebookStateFromSession(session, null); expect(result).not.toBeNull(); invariant(result, "result is null"); expect(result.cellRuntime[CELL_1].output).toEqual({ channel: "output", data: "Plain text", mimetype: "text/plain", timestamp: 0, }); }); it("handles data output with empty data object", () => { const dataOutput = { type: "data" as const, data: {}, }; const session = createSession([ createSessionCell("cell-1", [dataOutput]), ]); const result = notebookStateFromSession(session, null); expect(result).not.toBeNull(); invariant(result, "result is null"); expect(result.cellRuntime[CELL_1].output).toEqual({ channel: "output", data: undefined, mimetype: undefined, timestamp: 0, }); }); it("returns correct structure with all expected properties", () => { const session = createSession([createSessionCell("cell-1")]); const result = notebookStateFromSession(session, null); expect(result).not.toBeNull(); invariant(result, "result is null"); expect(result).toHaveProperty("cellIds"); expect(result).toHaveProperty("cellData"); expect(result).toHaveProperty("cellRuntime"); expect(result).toHaveProperty("cellHandles"); expect(result).toHaveProperty("history"); expect(result).toHaveProperty("scrollKey"); expect(result).toHaveProperty("cellLogs"); expect(result.cellHandles).toEqual({}); expect(result.history).toEqual([]); expect(result.scrollKey).toBeNull(); expect(result.cellLogs).toEqual([]); }); }); describe("session and notebook merge with code values abcdef", () => { it("merges session and notebook with code values a-f and correct hashes", () => { // md5 hashes for 'a' through 'f' const hashes = { a: "0cc175b9c0f1b6a831c399e269772661", b: "92eb5ffee6ae2fec3ad71c777531578f", c: "4a8a08f09d37b73795649038408b5f33", d: "8277e0910d750195b448797616e091ad", e: "e1671797c52e15f763380b45e841ec32", f: "8fa14cdd754f91cc6554c9e71929cce7", }; // Create notebook cells with code values 'a' through 'f' const notebookCells = [ createNotebookCell("cell-a", "a", null, null, hashes.a), createNotebookCell("cell-b", "b", null, null, hashes.b), createNotebookCell("cell-c", "c", null, null, hashes.c), createNotebookCell("cell-d", "d", null, null, hashes.d), createNotebookCell("cell-e", "e", null, null, hashes.e), createNotebookCell("cell-f", "f", null, null, hashes.f), ]; // Create session cells with matching code_hashes const sessionCells = [ createSessionCell( "cell-a", [{ type: "data", data: { "text/plain": "A!" } }], [], hashes.a, ), createSessionCell( "cell-b", [{ type: "data", data: { "text/plain": "B!" } }], [], hashes.b, ), createSessionCell( "cell-c", [{ type: "data", data: { "text/plain": "C!" } }], [], hashes.c, ), createSessionCell( "cell-d", [{ type: "data", data: { "text/plain": "D!" } }], [], hashes.d, ), createSessionCell( "cell-e", [{ type: "data", data: { "text/plain": "E!" } }], [], hashes.e, ), createSessionCell( "cell-f", [{ type: "data", data: { "text/plain": "F!" } }], [], hashes.f, ), ]; const session = createSession(sessionCells); const notebook = createNotebook(notebookCells); const result = notebookStateFromSession(session, notebook); expect(result).not.toBeNull(); invariant(result, "result is null"); // Should have all cell IDs in order expect(result.cellIds.inOrderIds).toEqual( MultiColumn.from([ ["cell-a", "cell-b", "cell-c", "cell-d", "cell-e", "cell-f"], ]).inOrderIds, ); // Should have correct code and output for each cell for (const code of ["a", "b", "c", "d", "e", "f"]) { const cid = cellId(`cell-${code}`); expect(result.cellData[cid].code).toBe(code); expect(result.cellRuntime[cid].output).toEqual({ channel: "output", data: `${code.toUpperCase()}!`, mimetype: "text/plain", timestamp: 0, }); } }); it("merges session and notebook with abcde -> aczeg edit distance scenario", () => { // md5 hashes for the codes const hashes = { a: "0cc175b9c0f1b6a831c399e269772661", b: "92eb5ffee6ae2fec3ad71c777531578f", c: "4a8a08f09d37b73795649038408b5f33", d: "8277e0910d750195b448797616e091ad", e: "e1671797c52e15f763380b45e841ec32", z: "fbade9e36a3f36d3d676c1b808451dd7", // hash for 'z' g: "b2f5ff47436671b6e533d8dc3614845d", // hash for 'g' }; // Session has cells with code 'a', 'b', 'c', 'd', 'e' const sessionCells = [ createSessionCell( "cell-a", [{ type: "data", data: { "text/plain": "A!" } }], [], hashes.a, ), createSessionCell( "cell-b", [{ type: "data", data: { "text/plain": "B!" } }], [], hashes.b, ), createSessionCell( "cell-c", [{ type: "data", data: { "text/plain": "C!" } }], [], hashes.c, ), createSessionCell( "cell-d", [{ type: "data", data: { "text/plain": "D!" } }], [], hashes.d, ), createSessionCell( "cell-e", [{ type: "data", data: { "text/plain": "E!" } }], [], hashes.e, ), ]; // Notebook has cells with code 'a', 'c', 'z', 'e', 'g' const notebookCells = [ createNotebookCell("cell-a", "a", null, null, hashes.a), createNotebookCell("cell-c", "c", null, null, hashes.c), createNotebookCell("cell-z", "z", null, null, hashes.z), createNotebookCell("cell-e", "e", null, null, hashes.e), createNotebookCell("cell-g", "g", null, null, hashes.g), ]; const session = createSession(sessionCells); const notebook = createNotebook(notebookCells); const result = notebookStateFromSession(session, notebook); expect(result).not.toBeNull(); invariant(result, "result is null"); // Should have notebook cell IDs in order (notebook is canonical) expect(result.cellIds.inOrderIds).toEqual( MultiColumn.from([["cell-a", "cell-c", "cell-z", "cell-e", "cell-g"]]) .inOrderIds, ); // Should have correct code for each cell expect(result.cellData[cellId("cell-a")].code).toBe("a"); expect(result.cellData[cellId("cell-c")].code).toBe("c"); expect(result.cellData[cellId("cell-z")].code).toBe("z"); expect(result.cellData[cellId("cell-e")].code).toBe("e"); expect(result.cellData[cellId("cell-g")].code).toBe("g"); // Should have session outputs for matching cells (a, c, e) expect(result.cellRuntime[cellId("cell-a")].output).toEqual({ channel: "output", data: "A!", mimetype: "text/plain", timestamp: 0, }); expect(result.cellRuntime[cellId("cell-c")].output).toEqual({ channel: "output", data: "C!", mimetype: "text/plain", timestamp: 0, }); expect(result.cellRuntime[cellId("cell-e")].output).toEqual({ channel: "output", data: "E!", mimetype: "text/plain", timestamp: 0, }); // Should have no output for new cells (z, g) - they get stub session cells expect(result.cellRuntime[cellId("cell-z")].output).toBeNull(); expect(result.cellRuntime[cellId("cell-g")].output).toBeNull(); // Should log warning about different cells expect(Logger.warn).toHaveBeenCalledWith( "Session and notebook have different cells, attempted merge.", ); }); it("merges session and notebook with mismatched cells and swapped code hashes", () => { // This test simulates a real-world scenario where: // - Session has cells cell-999, cell-1, cell-2 with certain outputs // - Notebook has cells cell-1, cell-2, cell-3 with different code // - The code hashes are mismatched, showing code has changed // // The merge algorithm uses edit distance on code hashes to match cells: // - Session cell-1 (hash=moMd) matches Notebook cell-2 (hash=moMd) // - Session cell-2 (hash=slider) matches Notebook cell-3 (hash=slider) // - Session cell-999 (hash=null) doesn't match anything (null hashes never match) // - Notebook cell-1 (hash=null) gets a stub session cell const hashes = { moMd: "2260380a88f9b8759c0ccb1230f1498e", // hash for mo.md("Hello") slider: "34938d98b7a6c88431d6cad23ea0a574", // hash for x = mo.ui.slider(0, 10) }; // Session has cells: cell-999 (error, no hash), cell-1 (markdown, hash=moMd), cell-2 (empty, hash=slider) const sessionCells = [ createSessionCell( "cell-999", [ { type: "error", ename: "exception", evalue: "division by zero", traceback: [], }, ], [ { type: "stream", name: "stderr", text: "Traceback (most recent call last): File