/* Copyright 2026 Marimo. All rights reserved. */ import { fireEvent, render, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { copyToClipboard } from "@/utils/copy"; import { JsonOutput } from "../JsonOutput"; vi.mock("@/utils/copy", () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined), })); describe("JsonOutput interactions", () => { it("copies all descendants of a paginated parent and shows the real size", async () => { const data = { rows: Array.from({ length: 120 }, (_, i) => i) }; const { getAllByRole, getByText } = render( , ); expect(getByText("120 Items")).toBeInTheDocument(); fireEvent.click(getAllByRole("button", { name: "Copy to clipboard" })[0]); await waitFor(() => expect(copyToClipboard).toHaveBeenLastCalledWith( JSON.stringify(data, null, 2), ), ); }); it("paginates independently and resets when the data changes", () => { const values = Array.from({ length: 70 }, (_, i) => i); const { getAllByRole, queryByRole, rerender } = render( , ); fireEvent.click(getAllByRole("button", { name: "... 40 more items" })[0]); expect(getAllByRole("button", { name: "... 40 more items" })).toHaveLength( 1, ); expect( queryByRole("button", { name: "... 10 more items" }), ).toBeInTheDocument(); rerender(); expect( queryByRole("button", { name: "... 10 more items" }), ).not.toBeInTheDocument(); expect(getAllByRole("button", { name: "... 40 more items" })).toHaveLength( 2, ); }); it("exposes expansion controls and long text as buttons", () => { const text = "a".repeat(150); const { getByRole, getAllByRole, queryByText } = render( , ); const root = getByRole("button", { name: "Collapse root" }); expect(root).toHaveAttribute("aria-expanded", "true"); fireEvent.click(root); expect(queryByText("plain")).not.toBeInTheDocument(); const collapsedRoot = getByRole("button", { name: "Expand root" }); expect(collapsedRoot).toHaveAttribute("aria-expanded", "false"); fireEvent.click(collapsedRoot); expect(getByRole("button", { name: "Collapse root" })).toHaveAttribute( "aria-expanded", "true", ); expect(queryByText("plain")).toBeInTheDocument(); const buttons = getAllByRole("button", { name: `${"a".repeat(100)}...` }); expect(buttons).toHaveLength(2); fireEvent.keyDown(buttons[0], { key: "Enter" }); expect(buttons[0]).toHaveAttribute("aria-expanded", "true"); fireEvent.keyDown(buttons[0], { key: " " }); expect(buttons[0]).toHaveAttribute("aria-expanded", "false"); }); it("initially collapses containers below the fifth level", () => { const data = { one: { two: { three: { four: { five: { leaf: "value" } } } } }, }; const { getByRole, queryByText, getByText } = render( , ); expect(queryByText("leaf")).not.toBeInTheDocument(); const collapsed = getByRole("button", { name: "Expand five" }); expect(collapsed).toHaveAttribute("aria-expanded", "false"); fireEvent.click(collapsed); expect(getByText("leaf")).toBeInTheDocument(); const expanded = getByRole("button", { name: "Collapse five" }); expect(expanded).toHaveAttribute("aria-expanded", "true"); fireEvent.click(expanded); expect(queryByText("leaf")).not.toBeInTheDocument(); expect(getByRole("button", { name: "Expand five" })).toHaveAttribute( "aria-expanded", "false", ); }); it.each([null, undefined, true, 42, Number.NaN, "text"])( "renders scalar %j without tree state", (data) => { const { container } = render(); expect(container.textContent).toBe(JSON.stringify(data) ?? ""); expect(container.querySelector(".marimo-json-output")).toBeNull(); }, ); it("updates Python and JSON renderers when the mode changes", () => { const data = { yes: true, no: false, none: null, "text/plain+int:2": "value", }; const { container, rerender } = render(); expect(container.textContent).toContain("True"); expect(container.textContent).not.toContain("text/plain+int:2"); rerender(); expect(container.textContent).toContain("true"); expect(container.textContent).toContain("false"); expect(container.textContent).toContain("null"); expect(container.textContent).not.toContain("True"); expect(container.textContent).toContain('"text/plain+int:2"'); rerender(); expect(container.textContent).toContain("True"); expect(container.textContent).not.toContain("text/plain+int:2"); }); it.each(["python", "json"] as const)( "copies marker-like user strings in %s mode", async (valueTypes) => { const data = { marker: "\u0000show_more\u000042|$", replacement: "None", }; const { getAllByRole } = render( , ); expect( getAllByRole("button", { name: "Copy to clipboard" }), ).toHaveLength(3); fireEvent.click(getAllByRole("button", { name: "Copy to clipboard" })[0]); await waitFor(() => expect(copyToClipboard).toHaveBeenLastCalledWith( JSON.stringify(data, null, 2), ), ); }, ); it("paginates arrays after expanding beyond the former depth cutoff", () => { const data = { a: { b: { c: { d: { e: { f: { rows: Array.from({ length: 100 }, (_, i) => i) } } }, }, }, }, }; const { getByRole, getByText, queryByRole } = render( , ); for (const name of ["e", "f", "rows"]) { fireEvent.click(getByRole("button", { name: `Expand ${name}` })); } expect(getByText("100 Items")).toBeInTheDocument(); fireEvent.click(getByRole("button", { name: "... 70 more items" })); expect( getByRole("button", { name: "... 40 more items" }), ).toBeInTheDocument(); expect( queryByRole("button", { name: "... 70 more items" }), ).not.toBeInTheDocument(); }); }); describe("JsonOutput interaction regressions", () => { it("removes the page control after the last page without collapsing the tree", () => { const data = Array.from({ length: 31 }, (_, i) => i); const { getByRole, queryByRole, getByText } = render( , ); fireEvent.click(getByRole("button", { name: "... 1 more items" })); expect( queryByRole("button", { name: /more items/ }), ).not.toBeInTheDocument(); expect(getByRole("button", { name: "Collapse root" })).toBeInTheDocument(); expect(getByText("31 Items")).toBeInTheDocument(); }); it("does not reuse a previous dataset's page size", () => { const matrix = Array.from({ length: 10 }, () => Array.from({ length: 60 }, (_, i) => i), ); const { rerender, getByRole, queryByRole } = render( , ); const data = Array.from({ length: 70 }, (_, i) => i); rerender(); fireEvent.click(getByRole("button", { name: "... 40 more items" })); expect( getByRole("button", { name: "... 10 more items" }), ).toBeInTheDocument(); expect( queryByRole("button", { name: "... 35 more items" }), ).not.toBeInTheDocument(); }); it("does not copy stale data or use the previous display mode", async () => { const { rerender, getAllByRole, getByRole } = render( , ); const data = { fresh: false, text: "text/plain:literal" }; rerender(); fireEvent.click(getAllByRole("button", { name: "Copy to clipboard" })[0]); await waitFor(() => expect(copyToClipboard).toHaveBeenLastCalledWith( JSON.stringify(data, null, 2), ), ); expect(getByRole("button", { name: "Collapse root" })).toBeInTheDocument(); rerender(); fireEvent.click(getAllByRole("button", { name: "Copy to clipboard" })[0]); await waitFor(() => expect(copyToClipboard).toHaveBeenLastCalledWith( '{\n "fresh": False,\n "text": "literal"\n}', ), ); }); it("does not retain decoded MIME text after switching to JSON", () => { const data = { tuple: "text/plain+tuple:[42]", html: "text/html:rich", }; const { container, rerender, getAllByRole } = render( , ); expect(container.querySelector("b")).toHaveTextContent("rich"); expect(getAllByRole("button", { name: "Copy to clipboard" })).toHaveLength( 2, ); rerender(); expect(container.querySelector("b")).toBeNull(); expect(container.textContent).toContain('"tuple":"text/plain+tuple:[42]"'); expect(container.textContent).toContain('"html":"text/html:rich"'); expect(getAllByRole("button", { name: "Copy to clipboard" })).toHaveLength( 3, ); }); it("ignores unrelated keys on long-text controls", () => { const text = "x".repeat(150); const { getByRole } = render(); const button = getByRole("button", { name: `${"x".repeat(100)}...` }); fireEvent.keyDown(button, { key: "Escape" }); fireEvent.keyDown(button, { key: "ArrowDown" }); expect(button).toHaveAttribute("aria-expanded", "false"); }); });