@@ -5,6 +5,9 @@ import { create, type ReactTestRenderer } from "react-test-renderer";
55import { describe , expect , it , vi } from "vite-plus/test" ;
66
77import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting" ;
8+ import * as mermaidRendering from "../lib/mermaidRendering" ;
9+ import { MarkdownDiffBlock } from "./MarkdownDiffBlock" ;
10+ import { MarkdownMermaidBlock } from "./MarkdownMermaidBlock" ;
811import { GitHubIcon } from "./Icons" ;
912import { Button } from "./ui/button" ;
1013import { setMarkdownTaskChecked } from "./files/filePreviewMode" ;
@@ -72,6 +75,138 @@ function codeButton(renderer: ReactTestRenderer, label: string) {
7275 return button . props as ComponentProps < typeof Button > ;
7376}
7477
78+ describe ( "ChatMarkdown code previews" , ( ) => {
79+ it . each ( [ "diff" , "PATCH" ] ) (
80+ "switches %s snippets between preview and source without changing copied code" ,
81+ async ( language ) => {
82+ const source = " unchanged\n-old\n+new\n" ;
83+ const writeText = vi . fn ( async ( _text : string ) => { } ) ;
84+ vi . stubGlobal ( "navigator" , { clipboard : { writeText } } ) ;
85+ vi . stubGlobal ( "IS_REACT_ACT_ENVIRONMENT" , true ) ;
86+ let renderer : ReactTestRenderer | undefined ;
87+ try {
88+ await act ( async ( ) => {
89+ renderer = create (
90+ < ChatMarkdown
91+ cwd = "/tmp/project"
92+ text = { `\`\`\`${ language } \n${ source } \`\`\`` }
93+ parseRawHtml = { false }
94+ /> ,
95+ ) ;
96+ } ) ;
97+ const preview = renderer ! . root . findByType ( MarkdownDiffBlock ) ;
98+ expect (
99+ preview
100+ . findAllByType ( "span" )
101+ . flatMap ( ( line ) => line . children )
102+ . join ( "" ) ,
103+ ) . toBe ( source ) ;
104+ const showSource = codeButton ( renderer ! , "Show source" ) ;
105+ await act ( async ( ) => {
106+ showSource . onClick ?.( { } as Parameters < NonNullable < typeof showSource . onClick > > [ 0 ] ) ;
107+ } ) ;
108+ expect ( renderer ! . root . findAllByType ( MarkdownDiffBlock ) ) . toHaveLength ( 0 ) ;
109+ const copy = codeButton ( renderer ! , "Copy code" ) ;
110+ await act ( async ( ) => {
111+ copy . onClick ?.( { } as Parameters < NonNullable < typeof copy . onClick > > [ 0 ] ) ;
112+ } ) ;
113+ expect ( writeText ) . toHaveBeenCalledWith ( source ) ;
114+ const showPreview = codeButton ( renderer ! , "Show preview" ) ;
115+ await act ( async ( ) => {
116+ showPreview . onClick ?.( { } as Parameters < NonNullable < typeof showPreview . onClick > > [ 0 ] ) ;
117+ } ) ;
118+ expect (
119+ renderer ! . root
120+ . findByType ( MarkdownDiffBlock )
121+ . findAllByType ( "span" )
122+ . flatMap ( ( line ) => line . children )
123+ . join ( "" ) ,
124+ ) . toBe ( source ) ;
125+ } finally {
126+ await act ( async ( ) => renderer ?. unmount ( ) ) ;
127+ vi . unstubAllGlobals ( ) ;
128+ vi . restoreAllMocks ( ) ;
129+ }
130+ } ,
131+ ) ;
132+
133+ it ( "renders Mermaid only after streaming ends and lets the user return to its source" , async ( ) => {
134+ vi . stubGlobal ( "IS_REACT_ACT_ENVIRONMENT" , true ) ;
135+ const renderDiagram = vi
136+ . spyOn ( mermaidRendering , "renderMermaidDiagram" )
137+ . mockResolvedValue ( "<svg><text>Draft</text></svg>" ) ;
138+ const text = "```mermaid\nflowchart LR\nDraft-->Review\n```" ;
139+ let renderer : ReactTestRenderer | undefined ;
140+ try {
141+ await act ( async ( ) => {
142+ renderer = create ( < ChatMarkdown cwd = "/tmp/project" text = { text } isStreaming /> , {
143+ createNodeMock : ( element ) =>
144+ element . type === "div" &&
145+ ( element . props as ComponentProps < "div" > ) . className === "chat-markdown-mermaid"
146+ ? { }
147+ : null ,
148+ } ) ;
149+ } ) ;
150+ expect ( renderDiagram ) . not . toHaveBeenCalled ( ) ;
151+ expect ( renderer ! . root . findAllByType ( MarkdownMermaidBlock ) ) . toHaveLength ( 0 ) ;
152+ await act ( async ( ) => {
153+ renderer ! . update ( < ChatMarkdown cwd = "/tmp/project" text = { text } /> ) ;
154+ } ) ;
155+ expect (
156+ renderer ! . root . findByProps ( { role : "img" } ) . props . dangerouslySetInnerHTML . __html ,
157+ ) . toContain ( "Draft" ) ;
158+ const showSource = codeButton ( renderer ! , "Show source" ) ;
159+ await act ( async ( ) => {
160+ showSource . onClick ?.( { } as Parameters < NonNullable < typeof showSource . onClick > > [ 0 ] ) ;
161+ } ) ;
162+ expect ( renderer ! . root . findAllByProps ( { role : "img" } ) ) . toHaveLength ( 0 ) ;
163+ const showPreview = codeButton ( renderer ! , "Show preview" ) ;
164+ await act ( async ( ) => {
165+ showPreview . onClick ?.( { } as Parameters < NonNullable < typeof showPreview . onClick > > [ 0 ] ) ;
166+ } ) ;
167+ expect (
168+ renderer ! . root . findByProps ( { role : "img" } ) . props . dangerouslySetInnerHTML . __html ,
169+ ) . toContain ( "Draft" ) ;
170+ } finally {
171+ await act ( async ( ) => renderer ?. unmount ( ) ) ;
172+ vi . unstubAllGlobals ( ) ;
173+ vi . restoreAllMocks ( ) ;
174+ }
175+ } ) ;
176+
177+ it ( "keeps malformed Mermaid source readable when diagram rendering fails" , async ( ) => {
178+ vi . stubGlobal ( "IS_REACT_ACT_ENVIRONMENT" , true ) ;
179+ vi . spyOn ( mermaidRendering , "renderMermaidDiagram" ) . mockRejectedValue (
180+ new Error ( "Invalid diagram" ) ,
181+ ) ;
182+ let renderer : ReactTestRenderer | undefined ;
183+ try {
184+ await act ( async ( ) => {
185+ renderer = create (
186+ < ChatMarkdown cwd = "/tmp/project" text = { "```mermaid\ninvalid diagram\n```" } /> ,
187+ {
188+ createNodeMock : ( element ) =>
189+ element . type === "div" &&
190+ ( element . props as ComponentProps < "div" > ) . className === "chat-markdown-mermaid"
191+ ? { }
192+ : null ,
193+ } ,
194+ ) ;
195+ } ) ;
196+ expect ( renderer ! . root . findByProps ( { role : "status" } ) . children . join ( "" ) ) . toBe (
197+ "Unable to render this diagram. Showing source." ,
198+ ) ;
199+ expect ( renderer ! . root . findByType ( "pre" ) . findByType ( "code" ) . children . join ( "" ) ) . toBe (
200+ "invalid diagram\n" ,
201+ ) ;
202+ } finally {
203+ await act ( async ( ) => renderer ?. unmount ( ) ) ;
204+ vi . unstubAllGlobals ( ) ;
205+ vi . restoreAllMocks ( ) ;
206+ }
207+ } ) ;
208+ } ) ;
209+
75210describe ( "ChatMarkdown favicon privacy" , ( ) => {
76211 it ( "suppresses private link images while preserving public links across updates" , async ( ) => {
77212 vi . stubGlobal ( "IS_REACT_ACT_ENVIRONMENT" , true ) ;
0 commit comments