Skip to content

Commit 4e8d1b9

Browse files
authored
test: Assert Assistant code block (#305)
* refactor: Abstract code block to separate module Simplify automated testing. We mock `react-markdown` due to its ESM-only architecture, so this module cannot be tested through the chat message module. * test: Assert the code "copy" button * test: Assert the code "run" button * test: Assert inline code * test: Assert the "run" button logic * test: Assert code "copy" button * refactor: Relocate manual strip-ansi mock to file-based mock Co-located third-party manual mocks. * test: Assert code block displays past execution output * refactor: Add missing Hook dependency * refactor: Fix typo * test: Fix non-wp-cli code assertion Replace erroneous copy and paste with the intended test case.
1 parent 9da0511 commit 4e8d1b9

File tree

5 files changed

+281
-111
lines changed

5 files changed

+281
-111
lines changed

jest-setup.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,4 @@ jest.mock( './src/hooks/use-offline', () => ( {
3434
useOffline: jest.fn().mockReturnValue( false ),
3535
} ) );
3636

37-
jest.mock( 'strip-ansi', () => ( {
38-
default: jest.fn().mockImplementation( ( str: string ) => str ),
39-
} ) );
40-
4137
global.ResizeObserver = require( 'resize-observer-polyfill' );

src/__mocks__/strip-ansi.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function stripAnsi( str: string ) {
2+
return str;
3+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { Spinner } from '@wordpress/components';
2+
import { __ } from '@wordpress/i18n';
3+
import { useEffect } from 'react';
4+
import { ExtraProps } from 'react-markdown';
5+
import stripAnsi from 'strip-ansi';
6+
import { useExecuteWPCLI } from '../hooks/use-execute-cli';
7+
import Button from './button';
8+
import { ChatMessageProps } from './chat-message';
9+
import { CopyTextButton } from './copy-text-button';
10+
import { ExecuteIcon } from './icons/execute';
11+
12+
type ContextProps = Pick<
13+
ChatMessageProps,
14+
'blocks' | 'updateMessage' | 'projectPath' | 'messageId'
15+
>;
16+
17+
type CodeBlockProps = JSX.IntrinsicElements[ 'code' ] & ExtraProps;
18+
19+
export default function createCodeComponent( contextProps: ContextProps ) {
20+
return ( props: CodeBlockProps ) => <CodeBlock { ...contextProps } { ...props } />;
21+
}
22+
23+
function CodeBlock( props: ContextProps & CodeBlockProps ) {
24+
const content = String( props.children ).trim();
25+
const containsWPCommand = /\bwp\s/.test( content );
26+
const wpCommandCount = ( content.match( /\bwp\s/g ) || [] ).length;
27+
const containsSingleWPCommand = wpCommandCount === 1;
28+
const containsAngleBrackets = /<.*>/.test( content );
29+
30+
const { node, blocks, updateMessage, projectPath, messageId, ...htmlAttributes } = props;
31+
const {
32+
cliOutput,
33+
cliStatus,
34+
cliTime,
35+
isRunning,
36+
handleExecute,
37+
setCliOutput,
38+
setCliStatus,
39+
setCliTime,
40+
} = useExecuteWPCLI( content, projectPath, updateMessage, messageId );
41+
42+
useEffect( () => {
43+
if ( blocks ) {
44+
const block = blocks?.find( ( block ) => block.codeBlockContent === content );
45+
if ( block ) {
46+
setCliOutput( block?.cliOutput ? stripAnsi( block.cliOutput ) : null );
47+
setCliStatus( block?.cliStatus ?? null );
48+
setCliTime( block?.cliTime ?? null );
49+
}
50+
}
51+
}, [ blocks, cliOutput, content, setCliOutput, setCliStatus, setCliTime ] );
52+
53+
const { children, className } = props;
54+
const match = /language-(\w+)/.exec( className || '' );
55+
return match ? (
56+
<>
57+
<div className="p-3">
58+
<code className={ className } { ...htmlAttributes }>
59+
{ children }
60+
</code>
61+
</div>
62+
<div className="p-3 pt-1 flex justify-start items-center">
63+
<CopyTextButton
64+
text={ content }
65+
label={ __( 'Copy' ) }
66+
copyConfirmation={ __( 'Copied!' ) }
67+
showText={ true }
68+
variant="outlined"
69+
className="h-auto mr-2 !px-2.5 py-0.5 !p-[6px] font-sans select-none"
70+
iconSize={ 16 }
71+
></CopyTextButton>
72+
{ containsWPCommand && containsSingleWPCommand && ! containsAngleBrackets && (
73+
<Button
74+
icon={ <ExecuteIcon /> }
75+
onClick={ handleExecute }
76+
disabled={ isRunning }
77+
variant="outlined"
78+
className="h-auto mr-2 !px-2.5 py-0.5 font-sans select-none"
79+
>
80+
{ cliOutput ? __( 'Run again' ) : __( 'Run' ) }
81+
</Button>
82+
) }
83+
</div>
84+
{ isRunning && (
85+
<div className="p-3 flex justify-start items-center bg-[#2D3337] text-white">
86+
<Spinner className="!text-white [&>circle]:stroke-a8c-gray-60" />
87+
<span className="ml-2 font-sans">{ __( 'Running...' ) }</span>
88+
</div>
89+
) }
90+
{ ! isRunning && cliOutput && cliStatus && (
91+
<InlineCLI output={ cliOutput } status={ cliStatus } time={ cliTime } />
92+
) }
93+
</>
94+
) : (
95+
<code className={ className } { ...htmlAttributes }>
96+
{ children }
97+
</code>
98+
);
99+
}
100+
101+
interface InlineCLIProps {
102+
output?: string;
103+
status?: 'success' | 'error';
104+
time?: string | null;
105+
}
106+
107+
function InlineCLI( { output, status, time }: InlineCLIProps ) {
108+
return (
109+
<div className="p-3 bg-[#2D3337]">
110+
<div className="flex justify-between mb-2 font-sans">
111+
<span className={ status === 'success' ? 'text-[#63CE68]' : 'text-[#E66D6C]' }>
112+
{ status === 'success' ? __( 'Success' ) : __( 'Error' ) }
113+
</span>
114+
<span className="text-gray-400">{ time }</span>
115+
</div>
116+
<pre className="text-white !bg-transparent !m-0 !px-0">
117+
<code className="!bg-transparent !mx-0 !px-0 !text-nowrap">{ output }</code>
118+
</pre>
119+
</div>
120+
);
121+
}

src/components/chat-message.tsx

Lines changed: 12 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,14 @@
11
import * as Sentry from '@sentry/react';
22
import { speak } from '@wordpress/a11y';
3-
import { Spinner } from '@wordpress/components';
43
import { __ } from '@wordpress/i18n';
5-
import { useEffect } from 'react';
64
import Markdown, { ExtraProps } from 'react-markdown';
75
import remarkGfm from 'remark-gfm';
8-
import stripAnsi from 'strip-ansi';
9-
import { useExecuteWPCLI } from '../hooks/use-execute-cli';
106
import { useSiteDetails } from '../hooks/use-site-details';
117
import { cx } from '../lib/cx';
128
import { getIpcApi } from '../lib/get-ipc-api';
13-
import Button from './button';
14-
import { CopyTextButton } from './copy-text-button';
15-
import { ExecuteIcon } from './icons/execute';
9+
import createCodeComponent from './assistant-code-block';
1610

17-
interface ChatMessageProps {
11+
export interface ChatMessageProps {
1812
children: React.ReactNode;
1913
isUser: boolean;
2014
id: string;
@@ -38,26 +32,6 @@ interface ChatMessageProps {
3832
isUnauthenticated?: boolean;
3933
}
4034

41-
interface InlineCLIProps {
42-
output?: string;
43-
status?: 'success' | 'error';
44-
time?: string | null;
45-
}
46-
47-
const InlineCLI = ( { output, status, time }: InlineCLIProps ) => (
48-
<div className="p-3 bg-[#2D3337]">
49-
<div className="flex justify-between mb-2 font-sans">
50-
<span className={ status === 'success' ? 'text-[#63CE68]' : 'text-[#E66D6C]' }>
51-
{ status === 'success' ? __( 'Success' ) : __( 'Error' ) }
52-
</span>
53-
<span className="text-gray-400">{ time }</span>
54-
</div>
55-
<pre className="text-white !bg-transparent !m-0 !px-0">
56-
<code className="!bg-transparent !mx-0 !px-0 !text-nowrap">{ output }</code>
57-
</pre>
58-
</div>
59-
);
60-
6135
export const ChatMessage = ( {
6236
children,
6337
id,
@@ -69,84 +43,6 @@ export const ChatMessage = ( {
6943
updateMessage,
7044
isUnauthenticated,
7145
}: ChatMessageProps ) => {
72-
const CodeBlock = ( props: JSX.IntrinsicElements[ 'code' ] & ExtraProps ) => {
73-
const content = String( props.children ).trim();
74-
const containsWPCommand = /\bwp\s/.test( content );
75-
const wpCommandCount = ( content.match( /\bwp\s/g ) || [] ).length;
76-
const containsSingleWPCommand = wpCommandCount === 1;
77-
const containsAngleBrackets = /<.*>/.test( content );
78-
79-
const {
80-
cliOutput,
81-
cliStatus,
82-
cliTime,
83-
isRunning,
84-
handleExecute,
85-
setCliOutput,
86-
setCliStatus,
87-
setCliTime,
88-
} = useExecuteWPCLI( content, projectPath, updateMessage, messageId );
89-
90-
useEffect( () => {
91-
if ( blocks ) {
92-
const block = blocks?.find( ( block ) => block.codeBlockContent === content );
93-
if ( block ) {
94-
setCliOutput( block?.cliOutput ? stripAnsi( block.cliOutput ) : null );
95-
setCliStatus( block?.cliStatus ?? null );
96-
setCliTime( block?.cliTime ?? null );
97-
}
98-
}
99-
}, [ cliOutput, content, setCliOutput, setCliStatus, setCliTime ] );
100-
101-
const { children, className } = props;
102-
const match = /language-(\w+)/.exec( className || '' );
103-
const { node, ...propsSansNode } = props;
104-
return match ? (
105-
<>
106-
<div className="p-3">
107-
<code className={ className } { ...propsSansNode }>
108-
{ children }
109-
</code>
110-
</div>
111-
<div className="p-3 pt-1 flex justify-start items-center">
112-
<CopyTextButton
113-
text={ content }
114-
label={ __( 'Copy' ) }
115-
copyConfirmation={ __( 'Copied!' ) }
116-
showText={ true }
117-
variant="outlined"
118-
className="h-auto mr-2 !px-2.5 py-0.5 !p-[6px] font-sans select-none"
119-
iconSize={ 16 }
120-
></CopyTextButton>
121-
{ containsWPCommand && containsSingleWPCommand && ! containsAngleBrackets && (
122-
<Button
123-
icon={ <ExecuteIcon /> }
124-
onClick={ handleExecute }
125-
disabled={ isRunning }
126-
variant="outlined"
127-
className="h-auto mr-2 !px-2.5 py-0.5 font-sans select-none"
128-
>
129-
{ cliOutput ? __( 'Run again' ) : __( 'Run' ) }
130-
</Button>
131-
) }
132-
</div>
133-
{ isRunning && (
134-
<div className="p-3 flex justify-start items-center bg-[#2D3337] text-white">
135-
<Spinner className="!text-white [&>circle]:stroke-a8c-gray-60" />
136-
<span className="ml-2 font-sans">{ __( 'Running...' ) }</span>
137-
</div>
138-
) }
139-
{ ! isRunning && cliOutput && cliStatus && (
140-
<InlineCLI output={ cliOutput } status={ cliStatus } time={ cliTime } />
141-
) }
142-
</>
143-
) : (
144-
<code className={ className } { ...propsSansNode }>
145-
{ children }
146-
</code>
147-
);
148-
};
149-
15046
return (
15147
<div
15248
className={ cx(
@@ -173,7 +69,16 @@ export const ChatMessage = ( {
17369
{ typeof children === 'string' ? (
17470
<div className="assistant-markdown">
17571
<Markdown
176-
components={ { a: Anchor, code: CodeBlock, img: () => null } }
72+
components={ {
73+
a: Anchor,
74+
code: createCodeComponent( {
75+
blocks,
76+
messageId,
77+
projectPath,
78+
updateMessage,
79+
} ),
80+
img: () => null,
81+
} }
17782
remarkPlugins={ [ remarkGfm ] }
17883
>
17984
{ children }

0 commit comments

Comments
 (0)