/* Copyright 2026 Marimo. All rights reserved. */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { cellId } from "@/__tests__/branded"; import type { CellMessage } from "../../kernel/messages"; import { formatLogTimestamp, getCellLogsForMessage } from "../logs"; // Stable mock reference so every (re)import of use-toast sees the same spy, // even after vi.resetModules() clears the module cache between tests. const { toastMock } = vi.hoisted(() => ({ toastMock: vi.fn() })); vi.mock("@/components/ui/use-toast", () => ({ toast: toastMock })); describe("getCellLogsForMessage", () => { beforeEach(() => { // Mock console.log to avoid cluttering test output vi.spyOn(console, "log").mockImplementation(() => { // no-op }); }); afterEach(() => { vi.restoreAllMocks(); }); test("handles text/plain MIME type on stdout", () => { const cellMessage: CellMessage = { cell_id: cellId("cell-1"), console: [ { mimetype: "text/plain", channel: "stdout", data: "Hello, World!", timestamp: 1_234_567_890, }, ], output: null, status: "idle", stale_inputs: null, timestamp: 0, }; const logs = getCellLogsForMessage(cellMessage); expect(logs).toHaveLength(1); expect(logs[0]).toEqual({ timestamp: 1_234_567_890, level: "stdout", message: "Hello, World!", cellId: "cell-1", }); }); test("handles text/plain MIME type on stderr", () => { const cellMessage: CellMessage = { cell_id: cellId("cell-2"), console: [ { mimetype: "text/plain", channel: "stderr", data: "Error occurred", timestamp: 1_234_567_890, }, ], output: null, status: "idle", stale_inputs: null, timestamp: 0, }; const logs = getCellLogsForMessage(cellMessage); expect(logs).toHaveLength(1); expect(logs[0]).toEqual({ timestamp: 1_234_567_890, level: "stderr", message: "Error occurred", cellId: "cell-2", }); }); test("handles text/html MIME type and strips HTML tags", () => { const cellMessage: CellMessage = { cell_id: cellId("cell-3"), console: [ { mimetype: "text/html", channel: "stdout", data: 'Error: Something went wrong', timestamp: 1_234_567_890, }, ], output: null, status: "idle", stale_inputs: null, timestamp: 0, }; const logs = getCellLogsForMessage(cellMessage); expect(logs).toHaveLength(1); expect(logs[0]).toEqual({ timestamp: 1_234_567_890, level: "stdout", message: "Error: Something went wrong", cellId: "cell-3", }); }); test("handles text/html MIME type on stderr", () => { const cellMessage: CellMessage = { cell_id: cellId("cell-4"), console: [ { mimetype: "text/html", channel: "stderr", data: "
Critical Error: System failure
", timestamp: 1_234_567_890, }, ], output: null, status: "idle", stale_inputs: null, timestamp: 0, }; const logs = getCellLogsForMessage(cellMessage); expect(logs).toHaveLength(1); expect(logs[0]).toEqual({ timestamp: 1_234_567_890, level: "stderr", message: "Critical Error: System failure", cellId: "cell-4", }); }); test("handles application/vnd.marimo+traceback MIME type and strips HTML", () => { const cellMessage: CellMessage = { cell_id: cellId("cell-5"), console: [ { mimetype: "application/vnd.marimo+traceback", channel: "marimo-error", data: '
Traceback (most recent call last):
  File "test.py", line 1
', timestamp: 1_234_567_890, }, ], output: null, status: "idle", stale_inputs: null, timestamp: 0, }; const logs = getCellLogsForMessage(cellMessage); expect(logs).toHaveLength(1); expect(logs[0].level).toBe("stderr"); // marimo-error should be treated as stderr expect(logs[0].message).toContain("Traceback (most recent call last):"); expect(logs[0].message).toContain('File "test.py", line 1'); expect(logs[0].cellId).toBe("cell-5"); }); test("handles multiple console outputs with different MIME types", () => { const cellMessage: CellMessage = { cell_id: cellId("cell-7"), console: [ { mimetype: "text/plain", channel: "stdout", data: "Plain text output", timestamp: 1_234_567_890, }, { mimetype: "text/html", channel: "stdout", data: "HTML output", timestamp: 1_234_567_891, }, { mimetype: "application/vnd.marimo+traceback", channel: "stderr", data: "
Traceback error
", timestamp: 1_234_567_892, }, ], output: null, status: "idle", stale_inputs: null, timestamp: 0, }; const logs = getCellLogsForMessage(cellMessage); expect(logs).toHaveLength(3); expect(logs[0].message).toBe("Plain text output"); expect(logs[1].message).toBe("HTML output"); expect(logs[2].message).toBe("Traceback error"); }); test("uses Date.now() when timestamp is missing", () => { const now = Date.now(); vi.spyOn(Date, "now").mockReturnValue(now); const cellMessage: CellMessage = { cell_id: cellId("cell-8"), console: [ { mimetype: "text/plain", channel: "stdout", data: "No timestamp", // timestamp is undefined }, ], output: null, status: "idle", stale_inputs: null, timestamp: 0, }; const logs = getCellLogsForMessage(cellMessage); expect(logs).toHaveLength(1); expect(logs[0].timestamp).toBe(now); }); test("ignores unsupported MIME types", () => { const cellMessage: CellMessage = { cell_id: cellId("cell-9"), console: [ { mimetype: "application/json", channel: "stdout", data: '{"key": "value"}', timestamp: 1_234_567_890, }, ], output: null, status: "idle", stale_inputs: null, timestamp: 0, }; const logs = getCellLogsForMessage(cellMessage); expect(logs).toHaveLength(0); }); test("ignores non-logging channels", () => { const cellMessage: CellMessage = { cell_id: cellId("cell-10"), console: [ { mimetype: "text/plain", channel: "pdb" as unknown as "stdout", // Non-logging channel data: "Should be ignored", timestamp: 1_234_567_890, }, ], output: null, status: "idle", stale_inputs: null, timestamp: 0, }; const logs = getCellLogsForMessage(cellMessage); expect(logs).toHaveLength(0); }); test("returns empty array when console is null", () => { const cellMessage: CellMessage = { cell_id: cellId("cell-11"), console: null as unknown as CellMessage["console"], output: null, status: "idle", stale_inputs: null, timestamp: 0, }; const logs = getCellLogsForMessage(cellMessage); expect(logs).toHaveLength(0); }); test("handles complex HTML with nested elements in text/html", () => { const cellMessage: CellMessage = { cell_id: cellId("cell-12"), console: [ { mimetype: "text/html", channel: "stdout", data: "
Nested HTML content
", timestamp: 1_234_567_890, }, ], output: null, status: "idle", stale_inputs: null, timestamp: 0, }; const logs = getCellLogsForMessage(cellMessage); expect(logs).toHaveLength(1); expect(logs[0].message).toBe("Nested HTML content"); }); test("handles marimo-error channel as stderr level", () => { const cellMessage: CellMessage = { cell_id: cellId("cell-13"), console: [ { mimetype: "text/plain", channel: "marimo-error", data: "Internal error", timestamp: 1_234_567_890, }, ], output: null, status: "idle", stale_inputs: null, timestamp: 0, }; const logs = getCellLogsForMessage(cellMessage); expect(logs).toHaveLength(1); expect(logs[0].level).toBe("stderr"); }); }); describe("getCellLogsForMessage - internal error toast", () => { // Re-imported per test after vi.resetModules() so the module-level // `didAlreadyToastError` flag starts fresh and all jotai atom references // (initialModeAtom, etc.) match the versions used by the reset logs.ts. let getLogs: typeof import("../logs").getCellLogsForMessage; let store: typeof import("@/core/state/jotai").store; let initialModeAtom: typeof import("@/core/mode").initialModeAtom; beforeEach(async () => { vi.spyOn(console, "log").mockImplementation(() => { // no-op }); vi.resetModules(); ({ getCellLogsForMessage: getLogs } = await import("../logs")); ({ store } = await import("@/core/state/jotai")); ({ initialModeAtom } = await import("@/core/mode")); }); afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); }); const makeErrorCellMessage = (id: CellMessage["cell_id"]): CellMessage => ({ cell_id: id, console: [], output: { mimetype: "application/vnd.marimo+error", data: [ { type: "exception", exception_type: "ValueError", msg: "something exploded", traceback: ["File foo.py, line 1", "ValueError: something exploded"], }, ], channel: "marimo-error", timestamp: 0, } as unknown as CellMessage["output"], status: "idle", stale_inputs: null, timestamp: 0, }); test("shows toast for internal errors in app (read) mode", () => { store.set(initialModeAtom, "read"); getLogs(makeErrorCellMessage(cellId("cell-err-1"))); expect(toastMock).toHaveBeenCalledTimes(1); expect(toastMock).toHaveBeenCalledWith( expect.objectContaining({ title: "An internal error occurred", variant: "danger", }), ); }); test("does not show toast for internal errors in edit mode", () => { store.set(initialModeAtom, "edit"); getLogs(makeErrorCellMessage(cellId("cell-err-2"))); expect(toastMock).not.toHaveBeenCalled(); }); test("edit-mode errors do not consume the once-per-session toast slot", () => { // Errors received while in edit mode should be silently skipped... store.set(initialModeAtom, "edit"); getLogs(makeErrorCellMessage(cellId("cell-err-3"))); expect(toastMock).not.toHaveBeenCalled(); // ...and a subsequent error in app mode should still toast. store.set(initialModeAtom, "read"); getLogs(makeErrorCellMessage(cellId("cell-err-4"))); expect(toastMock).toHaveBeenCalledTimes(1); }); test("toast only fires once across multiple app-mode errors", () => { store.set(initialModeAtom, "read"); getLogs(makeErrorCellMessage(cellId("cell-err-5"))); getLogs(makeErrorCellMessage(cellId("cell-err-6"))); expect(toastMock).toHaveBeenCalledTimes(1); }); test("suppresses toast when initial mode has not been set", () => { // Leave initialModeAtom at its default (undefined); getInitialAppMode // will throw and the logic should swallow it without toasting. getLogs(makeErrorCellMessage(cellId("cell-err-7"))); expect(toastMock).not.toHaveBeenCalled(); }); }); describe("formatLogTimestamp", () => { test("formats unix timestamp correctly", () => { // January 1, 2024, 12:00:00 PM UTC const timestamp = 1_704_110_400; const result = formatLogTimestamp(timestamp); // The result depends on the timezone, so we just check it's not empty // and contains expected time format elements expect(result).toBeTruthy(); expect(result).toMatch(/\d+:\d+:\d+/); // Should contain time format }); test("formats timestamp with AM/PM notation", () => { const timestamp = 1_704_110_400; // Noon const result = formatLogTimestamp(timestamp); // Should contain AM or PM expect(result).toMatch(/AM|PM/); }); test("returns 'Invalid Date' for invalid timestamp", () => { const invalidTimestamp = Number.NaN; const result = formatLogTimestamp(invalidTimestamp); expect(result).toBe("Invalid Date"); }); test("handles edge case: zero timestamp", () => { const timestamp = 0; // Unix epoch const result = formatLogTimestamp(timestamp); // Should format successfully (Jan 1, 1970) expect(result).toBeTruthy(); expect(result).toMatch(/\d+:\d+:\d+/); }); test("handles recent timestamp", () => { // Use a recent timestamp (seconds since epoch) const timestamp = Math.floor(Date.now() / 1000); const result = formatLogTimestamp(timestamp); expect(result).toBeTruthy(); expect(result).toMatch(/\d+:\d+:\d+/); expect(result).toMatch(/AM|PM/); }); });