// @vitest-environment jsdom import { describe, it, expect } from "vitest"; import { wrapStreamAnimation, createSkeletonPlaceholder, createStreamCaret, resolveStreamAnimation, streamAnimationContainerClass, streamAnimationBubbleClass, isWrappingAnimation, resolveStreamAnimationPlugin, registerStreamAnimationPlugin, unregisterStreamAnimationPlugin, listRegisteredStreamAnimations, applyStreamBuffer, ensurePluginActive, detachAllPlugins, } from "./stream-animation"; import type { AgentWidgetMessage, StreamAnimationPlugin } from "../types"; // Side-import the subpath plugin modules so tests can resolve their types. // `letter-rise` and `word-fade` are core built-ins and need no import. import "../animations/wipe"; import "../animations/glyph-cycle"; describe("wrapStreamAnimation: char mode", () => { it("wraps every character in a plain paragraph into a stream-char span", () => { const out = wrapStreamAnimation("

Hi!

", "char", "m1"); const parser = document.createElement("div"); parser.innerHTML = out; const spans = parser.querySelectorAll(".persona-stream-char"); expect(spans.length).toBe(3); expect(spans[0].textContent).toBe("H"); expect(spans[1].textContent).toBe("i"); expect(spans[2].textContent).toBe("!"); }); it("assigns monotonic --char-index starting at 0", () => { const out = wrapStreamAnimation("

abc

", "char", "m1"); const parser = document.createElement("div"); parser.innerHTML = out; const spans = parser.querySelectorAll(".persona-stream-char"); expect(spans[0].getAttribute("style")).toContain("--char-index: 0"); expect(spans[1].getAttribute("style")).toContain("--char-index: 1"); expect(spans[2].getAttribute("style")).toContain("--char-index: 2"); }); it("emits stable ids scoped by messageId", () => { const out = wrapStreamAnimation("

ab

", "char", "msg-42"); const parser = document.createElement("div"); parser.innerHTML = out; expect(parser.querySelector("#stream-c-msg-42-0")?.textContent).toBe("a"); expect(parser.querySelector("#stream-c-msg-42-1")?.textContent).toBe("b"); }); it("preserves formatting tags and wraps text inside them", () => { const out = wrapStreamAnimation("

Hi bold

", "char", "m1"); const parser = document.createElement("div"); parser.innerHTML = out; expect(parser.querySelector("strong")).toBeTruthy(); const spans = parser.querySelectorAll(".persona-stream-char"); // "Hi" (2) + "bold" (4) = 6 wrapped chars; the space between stays as a // plain text node so natural line-wrap works. expect(spans.length).toBe(6); expect(parser.querySelector("strong")?.querySelectorAll(".persona-stream-char").length).toBe(4); }); it("skips descendants of so code spans render as plain text", () => { const out = wrapStreamAnimation("

see x.y

", "char", "m1"); const parser = document.createElement("div"); parser.innerHTML = out; expect(parser.querySelector("code")?.textContent).toBe("x.y"); expect(parser.querySelector("code")?.querySelectorAll(".persona-stream-char").length).toBe(0); // "see" is 3 wrapped chars; the trailing space stays as a plain text node. expect(parser.querySelectorAll(".persona-stream-char").length).toBe(3); }); it("skips descendants of
", () => {
    const out = wrapStreamAnimation("
code\nblock
", "char", "m1"); const parser = document.createElement("div"); parser.innerHTML = out; expect(parser.querySelector("pre")?.textContent).toBe("code\nblock"); expect(parser.querySelectorAll(".persona-stream-char").length).toBe(0); }); it("skips descendants of ", () => { const out = wrapStreamAnimation('

go home

', "char", "m1"); const parser = document.createElement("div"); parser.innerHTML = out; expect(parser.querySelector("a")?.textContent).toBe("home"); expect(parser.querySelector("a")?.querySelectorAll(".persona-stream-char").length).toBe(0); // Only "go" is wrapped (2 chars); the trailing space stays plain. expect(parser.querySelectorAll(".persona-stream-char").length).toBe(2); }); it("leaves whitespace as a plain text node so word breaks survive", () => { const out = wrapStreamAnimation("

a b

", "char", "m1"); const parser = document.createElement("div"); parser.innerHTML = out; const spans = parser.querySelectorAll(".persona-stream-char"); expect(spans.length).toBe(2); expect(spans[0].textContent).toBe("a"); expect(spans[1].textContent).toBe("b"); const p = parser.querySelector("p")!; expect(p.childNodes.length).toBe(3); expect(p.childNodes[1].nodeType).toBe(Node.TEXT_NODE); expect(p.childNodes[1].textContent).toBe(" "); }); it("wraps each word run in a word-group so chars can't break mid-word", () => { const out = wrapStreamAnimation("

Hi there

", "char", "m1"); const parser = document.createElement("div"); parser.innerHTML = out; const groups = parser.querySelectorAll(".persona-stream-word-group"); expect(groups.length).toBe(2); expect(groups[0].textContent).toBe("Hi"); expect(groups[1].textContent).toBe("there"); expect(groups[0].querySelectorAll(".persona-stream-char").length).toBe(2); expect(groups[1].querySelectorAll(".persona-stream-char").length).toBe(5); }); it("keeps newlines and multi-space runs intact as text nodes", () => { const out = wrapStreamAnimation("

a\n b

", "char", "m1"); const parser = document.createElement("div"); parser.innerHTML = out; const p = parser.querySelector("p")!; expect(p.childNodes.length).toBe(3); expect(p.childNodes[1].nodeType).toBe(Node.TEXT_NODE); expect(p.childNodes[1].textContent).toBe("\n "); }); it("is idempotent on re-wrap for streaming: same input yields identical ids/indices", () => { const input = "

Hello

"; const first = wrapStreamAnimation(input, "char", "m1"); const second = wrapStreamAnimation(input, "char", "m1"); expect(first).toBe(second); }); it("extends indices for appended text across calls with growing content", () => { const first = wrapStreamAnimation("

Hi

", "char", "m1"); const second = wrapStreamAnimation("

Hi there

", "char", "m1"); const parse = (html: string) => { const div = document.createElement("div"); div.innerHTML = html; return div.querySelectorAll(".persona-stream-char"); }; const firstSpans = parse(first); const secondSpans = parse(second); // First two ids are stable: idiomorph match contract. The space between // "Hi" and "there" is a plain text node, not a span, so the next wrapped // char after "Hi" jumps to index 2 for "t" in "there". expect(firstSpans[0].id).toBe(secondSpans[0].id); expect(firstSpans[1].id).toBe(secondSpans[1].id); expect(secondSpans.length).toBeGreaterThan(firstSpans.length); // "there" is 5 wrapped chars, starting at index 2. expect(secondSpans[2].id).toBe("stream-c-m1-2"); expect(secondSpans[2].textContent).toBe("t"); }); it("returns input unchanged on empty string", () => { expect(wrapStreamAnimation("", "char", "m1")).toBe(""); }); }); describe("wrapStreamAnimation: word mode", () => { it("splits on whitespace and wraps each non-whitespace token", () => { const out = wrapStreamAnimation("

Hello brave world

", "word", "m1"); const parser = document.createElement("div"); parser.innerHTML = out; const words = parser.querySelectorAll(".persona-stream-word"); expect(words.length).toBe(3); expect(words[0].textContent).toBe("Hello"); expect(words[1].textContent).toBe("brave"); expect(words[2].textContent).toBe("world"); }); it("preserves whitespace between word spans as plain text", () => { const out = wrapStreamAnimation("

a b

", "word", "m1"); const parser = document.createElement("div"); parser.innerHTML = out; const p = parser.querySelector("p")!; // Expected DOM: a" "b expect(p.childNodes.length).toBe(3); expect(p.childNodes[1].nodeType).toBe(Node.TEXT_NODE); expect(p.childNodes[1].textContent).toBe(" "); }); it("assigns monotonic --word-index", () => { const out = wrapStreamAnimation("

one two three

", "word", "m1"); const parser = document.createElement("div"); parser.innerHTML = out; const words = parser.querySelectorAll(".persona-stream-word"); expect(words[0].getAttribute("style")).toContain("--word-index: 0"); expect(words[2].getAttribute("style")).toContain("--word-index: 2"); }); it("emits stable word ids scoped by messageId", () => { const out = wrapStreamAnimation("

foo bar

", "word", "abc"); const parser = document.createElement("div"); parser.innerHTML = out; expect(parser.querySelector("#stream-w-abc-0")?.textContent).toBe("foo"); expect(parser.querySelector("#stream-w-abc-1")?.textContent).toBe("bar"); }); it("skips words inside
, , ", () => {
    const out = wrapStreamAnimation(
      '

see foo and link

', "word", "m1" ); const parser = document.createElement("div"); parser.innerHTML = out; expect(parser.querySelector("code")?.querySelectorAll(".persona-stream-word").length).toBe(0); expect(parser.querySelector("a")?.querySelectorAll(".persona-stream-word").length).toBe(0); // "see", "and" are the wrapped words const wrapped = Array.from(parser.querySelectorAll(".persona-stream-word")).map( (el) => el.textContent ); expect(wrapped).toEqual(["see", "and"]); }); }); describe("resolveStreamAnimation", () => { it("returns all defaults when feature is undefined", () => { const resolved = resolveStreamAnimation(undefined); expect(resolved.type).toBe("none"); expect(resolved.placeholder).toBe("none"); expect(resolved.speed).toBe(120); expect(resolved.duration).toBe(1800); }); it("applies partial overrides", () => { const resolved = resolveStreamAnimation({ type: "typewriter", speed: 50 }); expect(resolved.type).toBe("typewriter"); expect(resolved.speed).toBe(50); expect(resolved.duration).toBe(1800); expect(resolved.placeholder).toBe("none"); }); }); describe("streamAnimationContainerClass / streamAnimationBubbleClass", () => { it("returns null for 'none'", () => { expect(streamAnimationContainerClass("none")).toBeNull(); expect(streamAnimationBubbleClass("none")).toBeNull(); }); it("maps per-unit types to container classes", () => { expect(streamAnimationContainerClass("typewriter")).toBe("persona-stream-typewriter"); expect(streamAnimationContainerClass("letter-rise")).toBe("persona-stream-letter-rise"); expect(streamAnimationContainerClass("word-fade")).toBe("persona-stream-word-fade"); expect(streamAnimationContainerClass("glyph-cycle")).toBe("persona-stream-glyph-cycle"); expect(streamAnimationContainerClass("wipe")).toBe("persona-stream-wipe"); }); it("puts pop-bubble on the bubble, not the content container", () => { expect(streamAnimationContainerClass("pop-bubble")).toBeNull(); expect(streamAnimationBubbleClass("pop-bubble")).toBe("persona-stream-pop"); }); }); describe("isWrappingAnimation", () => { it("is true for char and word modes", () => { expect(isWrappingAnimation("typewriter")).toBe(true); expect(isWrappingAnimation("letter-rise")).toBe(true); expect(isWrappingAnimation("glyph-cycle")).toBe(true); expect(isWrappingAnimation("word-fade")).toBe(true); expect(isWrappingAnimation("wipe")).toBe(true); }); it("is false for container-only modes and none", () => { expect(isWrappingAnimation("none")).toBe(false); expect(isWrappingAnimation("pop-bubble")).toBe(false); }); }); describe("createSkeletonPlaceholder", () => { it("renders a single full-width shimmer line", () => { const el = createSkeletonPlaceholder(); expect(el.classList.contains("persona-stream-skeleton")).toBe(true); expect(el.querySelectorAll(".persona-stream-skeleton-line").length).toBe(1); expect(el.getAttribute("data-preserve-animation")).toBe("stream-skeleton"); }); }); describe("createStreamCaret", () => { it("creates a span with data-preserve-animation so idiomorph keeps blink going", () => { const caret = createStreamCaret(); expect(caret.tagName).toBe("SPAN"); expect(caret.classList.contains("persona-stream-caret")).toBe(true); expect(caret.getAttribute("data-preserve-animation")).toBe("stream-caret"); expect(caret.getAttribute("aria-hidden")).toBe("true"); }); }); describe("plugin registry", () => { it("resolves built-in types without requiring registration", () => { expect(resolveStreamAnimationPlugin("typewriter")?.name).toBe("typewriter"); expect(resolveStreamAnimationPlugin("pop-bubble")?.name).toBe("pop-bubble"); }); it("returns null for 'none' and unknown types", () => { expect(resolveStreamAnimationPlugin("none")).toBeNull(); expect(resolveStreamAnimationPlugin("totally-made-up")).toBeNull(); }); it("prefers per-instance overrides over the global registry", () => { const custom: StreamAnimationPlugin = { name: "typewriter", containerClass: "custom-typewriter", wrap: "char", }; const plugin = resolveStreamAnimationPlugin("typewriter", { typewriter: custom }); expect(plugin?.containerClass).toBe("custom-typewriter"); }); it("registerStreamAnimationPlugin makes the plugin globally resolvable", () => { const sparkle: StreamAnimationPlugin = { name: "sparkle", containerClass: "sparkle-fx", wrap: "char", }; registerStreamAnimationPlugin(sparkle); expect(resolveStreamAnimationPlugin("sparkle")?.containerClass).toBe("sparkle-fx"); expect(listRegisteredStreamAnimations()).toContain("sparkle"); unregisterStreamAnimationPlugin("sparkle"); expect(resolveStreamAnimationPlugin("sparkle")).toBeNull(); }); it("unregisterStreamAnimationPlugin refuses to remove built-ins", () => { unregisterStreamAnimationPlugin("typewriter"); expect(resolveStreamAnimationPlugin("typewriter")?.name).toBe("typewriter"); }); }); describe("applyStreamBuffer", () => { const message = { id: "m1", role: "assistant", content: "" } as AgentWidgetMessage; it("passes through when streaming is false", () => { expect(applyStreamBuffer("abc", "word", null, message, false)).toBe("abc"); }); it("passes through when buffer is 'none'", () => { expect(applyStreamBuffer("abc def", "none", null, message, true)).toBe("abc def"); }); it("word mode trims to the last whitespace boundary", () => { expect(applyStreamBuffer("hello wor", "word", null, message, true)).toBe("hello"); expect(applyStreamBuffer("hello world ", "word", null, message, true)).toBe( "hello world" ); }); it("word mode hides all content until the first word boundary", () => { expect(applyStreamBuffer("partial", "word", null, message, true)).toBe(""); }); it("line mode trims to the last newline", () => { expect(applyStreamBuffer("line1\nmid", "line", null, message, true)).toBe("line1"); }); it("plugin.bufferContent takes precedence over the built-in strategy", () => { const plugin: StreamAnimationPlugin = { name: "capper", bufferContent: (content) => content.slice(0, 3), }; expect(applyStreamBuffer("hello world", "word", plugin, message, true)).toBe("hel"); }); }); describe("ensurePluginActive / detachAllPlugins", () => { it("injects plugin styles once and runs onAttach cleanup on detach", () => { const root = document.createElement("div"); document.body.appendChild(root); let attached = 0; let detached = 0; const plugin: StreamAnimationPlugin = { name: "test-attach", styles: ".test-attach { color: red; }", onAttach() { attached += 1; return () => { detached += 1; }; }, }; ensurePluginActive(plugin, root); ensurePluginActive(plugin, root); // second call is a no-op expect(attached).toBe(1); expect(root.querySelectorAll("style[data-persona-animation='test-attach']").length).toBe(1); detachAllPlugins(root); expect(detached).toBe(1); document.body.removeChild(root); }); it("re-injects plugin styles after the root's children are cleared", () => { const root = document.createElement("div"); document.body.appendChild(root); const plugin: StreamAnimationPlugin = { name: "test-reinject", styles: ".test-reinject { color: red; }", }; ensurePluginActive(plugin, root); expect(root.querySelectorAll("style[data-persona-animation='test-reinject']").length).toBe(1); // Simulate widget re-init: destroy callbacks run, then host is wiped. detachAllPlugins(root); root.innerHTML = ""; ensurePluginActive(plugin, root); expect(root.querySelectorAll("style[data-persona-animation='test-reinject']").length).toBe(1); document.body.removeChild(root); }); });