/* Copyright 2026 Marimo. All rights reserved. */ import { describe, expect, it } from "vitest"; import { StatefulOutputMessage } from "@/components/editor/output/ansi-reduce"; import type { OutputMessage } from "@/core/kernel/messages"; import { collapseConsoleOutputs, maybeMakeOutputStateful, } from "../collapseConsoleOutputs"; describe("collapseConsoleOutputs", () => { it("should collapse last two text/plain outputs on the same channel", () => { const consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "output", data: "Hello ", timestamp: 0, }, { mimetype: "text/plain", channel: "output", data: "World", timestamp: 0, }, ]; const result = collapseConsoleOutputs(consoleOutputs); expect(result[0].data).toMatchInlineSnapshot(`"Hello World"`); }); it("should collapse last two text/plain outputs on the same channel if they end in newlines", () => { const consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "output", data: "Hello\n", timestamp: 0, }, { mimetype: "text/plain", channel: "output", data: "World\n", timestamp: 0, }, ]; const result = collapseConsoleOutputs(consoleOutputs); expect(result[0].data).toMatchInlineSnapshot(`"Hello\nWorld\n"`); }); it("should not collapse outputs on different channels", () => { const consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "output", data: "Hello ", timestamp: 0, }, { mimetype: "text/plain", channel: "stdout", data: "World", timestamp: 0, }, ]; const result = collapseConsoleOutputs(consoleOutputs); expect(result).toEqual(consoleOutputs); }); it("should not collapse outputs of different mimetypes", () => { const consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "output", data: "Hello ", timestamp: 0, }, { mimetype: "text/html", channel: "output", data: "World", timestamp: 0, }, ]; const result = collapseConsoleOutputs(consoleOutputs); expect(result).toEqual(consoleOutputs); }); it("should handle carriage returns", () => { const consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "output", data: "Hello\rWorld", timestamp: 0, }, ]; const result = collapseConsoleOutputs(consoleOutputs); expect(result[0].data).toMatchInlineSnapshot('"World"'); }); it("should handle multiple carriage returns", () => { const consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "output", data: "Hello\rWorld\r!", timestamp: 0, }, ]; const result = collapseConsoleOutputs(consoleOutputs); expect(result[0].data).toMatchInlineSnapshot('"!orld"'); }); it("should handle carriage returns with newlines", () => { const consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "output", data: "Hello\nWorld\r!", timestamp: 0, }, ]; const result = collapseConsoleOutputs(consoleOutputs); expect(result).toHaveLength(1); expect(result[0].data).toMatchInlineSnapshot(` "Hello !orld" `); }); it("doesn't mutate the input", () => { const consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "output", data: "Hello ", timestamp: 0, }, { mimetype: "text/plain", channel: "output", data: "World", timestamp: 0, }, ]; const result = collapseConsoleOutputs(consoleOutputs); expect(result).not.toBe(consoleOutputs); expect(result[0]).not.toBe(consoleOutputs[0]); expect(result[1]).not.toBe(consoleOutputs[1]); }); it("should truncate head of text/plain single message", () => { const consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "output", data: "Hello\nWorld\nBye\nWorld", timestamp: 0, }, ]; const result = collapseConsoleOutputs(consoleOutputs, 2); expect(result.length).toBe(2); // First result is a warning re: truncation expect(result[0].data).toContain("Streaming output truncated"); // First two lines are truncated expect(result[1].data).toBe("Bye\nWorld"); }); it("should truncate head of text/plain multiple channels", () => { const consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "stdout", data: "A\nB\nC\nD\n", timestamp: 0, }, { mimetype: "text/plain", channel: "stderr", data: "E\nF\nG\nH\n", timestamp: 0, }, ]; const result = collapseConsoleOutputs(consoleOutputs, 7); expect(result.length).toBe(3); // First result is a warning re: truncation expect(result[0].data).toContain("Streaming output truncated"); // First two lines are truncated, leaving 2 lines expect(result[1].data).toBe("D\n"); // No truncation: 5 lines, for a total of 2 + 5 = 7 lines expect(result[2].data).toBe("E\nF\nG\nH\n"); }); it("should truncate head of text/plain same channel", () => { const consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "stdout", data: "A\nB\nC\nD\n", timestamp: 0, }, { mimetype: "text/plain", channel: "stdout", data: "E\nF\nG\nH\n", timestamp: 0, }, ]; const result = collapseConsoleOutputs(consoleOutputs, 7); // 1 warning message, and 1 message containing merged stdout expect(result.length).toBe(2); // First result is a warning re: truncation expect(result[0].data).toContain("Streaming output truncated"); // First two lines are truncated expect(result[1].data).toBe("C\nD\nE\nF\nG\nH\n"); }); it("should truncate head with text/html counting as one line", () => { const consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "stdout", data: "A\nB\nC\nD\n", timestamp: 0, }, { mimetype: "text/html", channel: "output", data: "
E\nF\nG\nH\n
", timestamp: 0, }, ]; const result = collapseConsoleOutputs(consoleOutputs, 3); // 1 warning message, and 1 message containing merged stdout expect(result.length).toBe(3); // First result is a warning re: truncation expect(result[0].data).toContain("Streaming output truncated"); // 2 lines expect(result[1].data).toBe("D\n"); // heuristic: non-text counts as 1 line ... expect(result[2].data).toBe("
E\nF\nG\nH\n
"); }); it("should not crash when truncating with a single output at the limit boundary", () => { // Create outputs that push truncation to the exact boundary const consoleOutputs: OutputMessage[] = [ { mimetype: "text/html", channel: "output", data: "
html1
", timestamp: 0, }, { mimetype: "text/html", channel: "output", data: "
html2
", timestamp: 0, }, ]; // With limit=1, truncation must handle edge cases gracefully const result = collapseConsoleOutputs(consoleOutputs, 1); expect(result[0].data).toContain("Streaming output truncated"); }); it("should handle truncation when cutoff indexes past the end of the array", () => { // With maxLines=0, the truncation loop never runs, causing cutoff // to index past the array. This exercises the `output == null` // defensive branch in truncateHead(). const consoleOutputs: OutputMessage[] = [ { mimetype: "text/html", channel: "output", data: "
content
", timestamp: 0, }, ]; const result = collapseConsoleOutputs(consoleOutputs, 0); expect(result).toHaveLength(1); expect(result[0].data).toContain("Streaming output truncated"); }); describe("ANSI escape sequences", () => { it("should handle cursor movement with collapse", () => { const consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "output", data: "Hello", timestamp: 0, }, { mimetype: "text/plain", channel: "output", data: "\u001B[5DWorld", // Move cursor back 5, write World timestamp: 0, }, ]; const result = collapseConsoleOutputs(consoleOutputs); expect(result[0].data).toBe("World"); }); it("should handle progress bar simulation", () => { // Simulate streaming: collapse is called after each new message let consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "stdout", data: "Progress: 0%\r", timestamp: 0, }, ]; consoleOutputs = collapseConsoleOutputs([ ...consoleOutputs, { mimetype: "text/plain", channel: "stdout", data: "Progress: 50%\r", timestamp: 0, }, ]); consoleOutputs = collapseConsoleOutputs([ ...consoleOutputs, { mimetype: "text/plain", channel: "stdout", data: "Progress: 100%", timestamp: 0, }, ]); expect(consoleOutputs[0].data).toBe("Progress: 100%"); }); it("should handle tqdm-like progress bars", () => { // Simulate streaming: collapse is called after each new message let consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "stdout", data: "Processing: | | 0/100\r", timestamp: 0, }, ]; consoleOutputs = collapseConsoleOutputs([ ...consoleOutputs, { mimetype: "text/plain", channel: "stdout", data: "Processing: |█████ | 50/100\r", timestamp: 0, }, ]); consoleOutputs = collapseConsoleOutputs([ ...consoleOutputs, { mimetype: "text/plain", channel: "stdout", data: "Processing: |██████████| 100/100", timestamp: 0, }, ]); expect(consoleOutputs[0].data).toBe("Processing: |██████████| 100/100"); }); it("should handle cursor up/down movements", () => { // Test that cursor movements within a single message are handled const consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "stdout", data: "Line 1\nLine 2\n\u001B[1AModified", timestamp: 0, }, ]; const result = collapseConsoleOutputs(consoleOutputs); // After moving up 1 line and writing "Modified", Line 2 gets overwritten expect(result[0].data).toBe("Line 1\nModified"); }); it("should handle clear screen sequence", () => { let consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "stdout", data: "Old content\nMore old content\n", timestamp: 0, }, ]; consoleOutputs = collapseConsoleOutputs([ ...consoleOutputs, { mimetype: "text/plain", channel: "stdout", data: "\u001B[2J", // Clear screen timestamp: 0, }, ]); consoleOutputs = collapseConsoleOutputs([ ...consoleOutputs, { mimetype: "text/plain", channel: "stdout", data: "New content", timestamp: 0, }, ]); expect(consoleOutputs[0].data).toBe("New content"); }); it("should handle erase line sequences", () => { let consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "stdout", data: "Hello World", timestamp: 0, }, ]; consoleOutputs = collapseConsoleOutputs([ ...consoleOutputs, { mimetype: "text/plain", channel: "stdout", data: "\u001B[2K", // Erase entire line timestamp: 0, }, ]); consoleOutputs = collapseConsoleOutputs([ ...consoleOutputs, { mimetype: "text/plain", channel: "stdout", data: "Goodbye", timestamp: 0, }, ]); expect(consoleOutputs[0].data).toBe(" Goodbye"); }); it("should handle cursor positioning", () => { let consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "stdout", data: "abc", timestamp: 0, }, ]; consoleOutputs = collapseConsoleOutputs([ ...consoleOutputs, { mimetype: "text/plain", channel: "stdout", data: "\u001B[1;2H", // Move to position (1,2) timestamp: 0, }, ]); consoleOutputs = collapseConsoleOutputs([ ...consoleOutputs, { mimetype: "text/plain", channel: "stdout", data: "XY", timestamp: 0, }, ]); expect(consoleOutputs[0].data).toBe("aXY"); }); it("should handle complex multi-line progress updates", () => { // Test multi-line updates with ANSI sequences all in one message const consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "stdout", data: "Task 1: 0%\nTask 2: 0%\n\u001B[2A\rTask 1: 100%\n\rTask 2: 100%\n", timestamp: 0, }, ]; const result = collapseConsoleOutputs(consoleOutputs); expect(result[0].data).toBe("Task 1: 100%\nTask 2: 100%\n"); }); it("should preserve ANSI state across multiple collapsed messages", () => { // Simulate streaming collapses let consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "stdout", data: "Loading", timestamp: 0, }, ]; consoleOutputs = collapseConsoleOutputs([ ...consoleOutputs, { mimetype: "text/plain", channel: "stdout", data: "\rLoading.", timestamp: 0, }, ]); consoleOutputs = collapseConsoleOutputs([ ...consoleOutputs, { mimetype: "text/plain", channel: "stdout", data: "\rLoading..", timestamp: 0, }, ]); consoleOutputs = collapseConsoleOutputs([ ...consoleOutputs, { mimetype: "text/plain", channel: "stdout", data: "\rLoading...", timestamp: 0, }, ]); consoleOutputs = collapseConsoleOutputs([ ...consoleOutputs, { mimetype: "text/plain", channel: "stdout", data: "\rComplete! ", timestamp: 0, }, ]); expect(consoleOutputs[0].data).toBe("Complete! "); }); }); describe("StatefulOutputMessage", () => { it("should preserve StatefulOutputMessage through collapse", () => { const statefulMessage = StatefulOutputMessage.create({ mimetype: "text/plain", channel: "stdout", data: "Hello\rWorld", timestamp: 0, }); const consoleOutputs: OutputMessage[] = [ statefulMessage, { mimetype: "text/plain", channel: "stdout", data: " Complete", timestamp: 0, }, ]; const result = collapseConsoleOutputs(consoleOutputs); expect(result.length).toBe(1); expect(result[0]).toBeInstanceOf(StatefulOutputMessage); expect(result[0].data).toBe("World Complete"); }); it("should handle mixing StatefulOutputMessage and regular messages", () => { let consoleOutputs: OutputMessage[] = [ { mimetype: "text/plain", channel: "stdout", data: "Line 1\n", timestamp: 0, }, ]; consoleOutputs = collapseConsoleOutputs([ ...consoleOutputs, { mimetype: "text/plain", channel: "stdout", data: "Progress: 0%", timestamp: 0, }, ]); consoleOutputs = collapseConsoleOutputs([ ...consoleOutputs, { mimetype: "text/plain", channel: "stdout", data: "\rProgress: 100%", timestamp: 0, }, ]); expect(consoleOutputs.length).toBe(1); expect(consoleOutputs[0]).toBeInstanceOf(StatefulOutputMessage); expect(consoleOutputs[0].data).toBe("Line 1\nProgress: 100%"); }); it("should handle StatefulOutputMessage with empty array input", () => { const consoleOutputs: OutputMessage[] = []; const result = collapseConsoleOutputs(consoleOutputs); expect(result).toEqual([]); }); it("should handle StatefulOutputMessage with single message", () => { const statefulMessage = StatefulOutputMessage.create({ mimetype: "text/plain", channel: "stdout", data: "Hello\rWorld", timestamp: 0, }); const result = collapseConsoleOutputs([statefulMessage]); expect(result.length).toBe(1); expect(result[0]).toBeInstanceOf(StatefulOutputMessage); expect(result[0].data).toBe("World"); }); }); describe("maybeMakeOutputStateful", () => { it("should convert regular string output to StatefulOutputMessage", () => { const output: OutputMessage = { mimetype: "text/plain", channel: "stdout", data: "Hello", timestamp: 0, }; const result = maybeMakeOutputStateful(output); expect(result).toBeInstanceOf(StatefulOutputMessage); expect(result.data).toBe("Hello"); }); it("should preserve StatefulOutputMessage as-is", () => { const statefulMessage = StatefulOutputMessage.create({ mimetype: "text/plain", channel: "stdout", data: "Hello", timestamp: 0, }); const result = maybeMakeOutputStateful(statefulMessage); expect(result).toBe(statefulMessage); }); it("should return non-string output as-is", () => { const output: OutputMessage = { mimetype: "application/json", channel: "output", data: { key: "value" }, timestamp: 0, }; const result = maybeMakeOutputStateful(output); expect(result).toBe(output); expect(result).not.toBeInstanceOf(StatefulOutputMessage); }); }); });