Skip to content

test: Assert Assistant code block #305

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jun 26, 2024
4 changes: 0 additions & 4 deletions jest-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,4 @@ jest.mock( './src/hooks/use-offline', () => ( {
useOffline: jest.fn().mockReturnValue( false ),
} ) );

jest.mock( 'strip-ansi', () => ( {
default: jest.fn().mockImplementation( ( str: string ) => str ),
} ) );
Comment on lines -37 to -39
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relocated to the __mocks__ directory to co-locate third-party mocks and make the mock universal for all tests.


global.ResizeObserver = require( 'resize-observer-polyfill' );
3 changes: 3 additions & 0 deletions src/__mocks__/strip-ansi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function stripAnsi( str: string ) {
return str;
}
121 changes: 121 additions & 0 deletions src/components/assistant-code-block.tsx
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I explored enabling Jest's experimental ESM support to remove the react-markdown mock (1264f3a, 3121004, 66f9b14). I was successful getting Jest tests to pass, but encountered errors building/running the app itself. So, I pivoted to relocating this logic instead to enable more straightforward unit tests.

Copy link
Contributor

@fluiddot fluiddot Jun 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer this disposition of having a separated component. Thanks David for making the change 🙇 !

Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { Spinner } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useEffect } from 'react';
import { ExtraProps } from 'react-markdown';
import stripAnsi from 'strip-ansi';
import { useExecuteWPCLI } from '../hooks/use-execute-cli';
import Button from './button';
import { ChatMessageProps } from './chat-message';
import { CopyTextButton } from './copy-text-button';
import { ExecuteIcon } from './icons/execute';

type ContextProps = Pick<
ChatMessageProps,
'blocks' | 'updateMessage' | 'projectPath' | 'messageId'
>;

type CodeBlockProps = JSX.IntrinsicElements[ 'code' ] & ExtraProps;

export default function createCodeComponent( contextProps: ContextProps ) {
return ( props: CodeBlockProps ) => <CodeBlock { ...contextProps } { ...props } />;
}

function CodeBlock( props: ContextProps & CodeBlockProps ) {
const content = String( props.children ).trim();
const containsWPCommand = /\bwp\s/.test( content );
const wpCommandCount = ( content.match( /\bwp\s/g ) || [] ).length;
const containsSingleWPCommand = wpCommandCount === 1;
const containsAngleBrackets = /<.*>/.test( content );

const { node, blocks, updateMessage, projectPath, messageId, ...htmlAttributes } = props;
const {
cliOutput,
cliStatus,
cliTime,
isRunning,
handleExecute,
setCliOutput,
setCliStatus,
setCliTime,
} = useExecuteWPCLI( content, projectPath, updateMessage, messageId );

useEffect( () => {
if ( blocks ) {
const block = blocks?.find( ( block ) => block.codeBlockContent === content );
if ( block ) {
setCliOutput( block?.cliOutput ? stripAnsi( block.cliOutput ) : null );
setCliStatus( block?.cliStatus ?? null );
setCliTime( block?.cliTime ?? null );
}
}
}, [ blocks, cliOutput, content, setCliOutput, setCliStatus, setCliTime ] );
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocks was added addressing the missing dependency lint warning.


const { children, className } = props;
const match = /language-(\w+)/.exec( className || '' );
return match ? (
<>
<div className="p-3">
<code className={ className } { ...htmlAttributes }>
{ children }
</code>
</div>
<div className="p-3 pt-1 flex justify-start items-center">
<CopyTextButton
text={ content }
label={ __( 'Copy' ) }
copyConfirmation={ __( 'Copied!' ) }
showText={ true }
variant="outlined"
className="h-auto mr-2 !px-2.5 py-0.5 !p-[6px] font-sans select-none"
iconSize={ 16 }
></CopyTextButton>
{ containsWPCommand && containsSingleWPCommand && ! containsAngleBrackets && (
<Button
icon={ <ExecuteIcon /> }
onClick={ handleExecute }
disabled={ isRunning }
variant="outlined"
className="h-auto mr-2 !px-2.5 py-0.5 font-sans select-none"
>
{ cliOutput ? __( 'Run again' ) : __( 'Run' ) }
</Button>
) }
</div>
{ isRunning && (
<div className="p-3 flex justify-start items-center bg-[#2D3337] text-white">
<Spinner className="!text-white [&>circle]:stroke-a8c-gray-60" />
<span className="ml-2 font-sans">{ __( 'Running...' ) }</span>
</div>
) }
{ ! isRunning && cliOutput && cliStatus && (
<InlineCLI output={ cliOutput } status={ cliStatus } time={ cliTime } />
) }
</>
) : (
<code className={ className } { ...htmlAttributes }>
{ children }
</code>
);
}

interface InlineCLIProps {
output?: string;
status?: 'success' | 'error';
time?: string | null;
}

function InlineCLI( { output, status, time }: InlineCLIProps ) {
return (
<div className="p-3 bg-[#2D3337]">
<div className="flex justify-between mb-2 font-sans">
<span className={ status === 'success' ? 'text-[#63CE68]' : 'text-[#E66D6C]' }>
{ status === 'success' ? __( 'Success' ) : __( 'Error' ) }
</span>
<span className="text-gray-400">{ time }</span>
</div>
<pre className="text-white !bg-transparent !m-0 !px-0">
<code className="!bg-transparent !mx-0 !px-0 !text-nowrap">{ output }</code>
</pre>
</div>
);
}
119 changes: 12 additions & 107 deletions src/components/chat-message.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
import * as Sentry from '@sentry/react';
import { speak } from '@wordpress/a11y';
import { Spinner } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useEffect } from 'react';
import Markdown, { ExtraProps } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import stripAnsi from 'strip-ansi';
import { useExecuteWPCLI } from '../hooks/use-execute-cli';
import { useSiteDetails } from '../hooks/use-site-details';
import { cx } from '../lib/cx';
import { getIpcApi } from '../lib/get-ipc-api';
import Button from './button';
import { CopyTextButton } from './copy-text-button';
import { ExecuteIcon } from './icons/execute';
import createCodeComponent from './assistant-code-block';

interface ChatMessageProps {
export interface ChatMessageProps {
children: React.ReactNode;
isUser: boolean;
id: string;
Expand All @@ -38,26 +32,6 @@ interface ChatMessageProps {
isUnauthenticated?: boolean;
}

interface InlineCLIProps {
output?: string;
status?: 'success' | 'error';
time?: string | null;
}

const InlineCLI = ( { output, status, time }: InlineCLIProps ) => (
<div className="p-3 bg-[#2D3337]">
<div className="flex justify-between mb-2 font-sans">
<span className={ status === 'success' ? 'text-[#63CE68]' : 'text-[#E66D6C]' }>
{ status === 'success' ? __( 'Success' ) : __( 'Error' ) }
</span>
<span className="text-gray-400">{ time }</span>
</div>
<pre className="text-white !bg-transparent !m-0 !px-0">
<code className="!bg-transparent !mx-0 !px-0 !text-nowrap">{ output }</code>
</pre>
</div>
);

export const ChatMessage = ( {
children,
id,
Expand All @@ -69,84 +43,6 @@ export const ChatMessage = ( {
updateMessage,
isUnauthenticated,
}: ChatMessageProps ) => {
const CodeBlock = ( props: JSX.IntrinsicElements[ 'code' ] & ExtraProps ) => {
const content = String( props.children ).trim();
const containsWPCommand = /\bwp\s/.test( content );
const wpCommandCount = ( content.match( /\bwp\s/g ) || [] ).length;
const containsSingleWPCommand = wpCommandCount === 1;
const containsAngleBrackets = /<.*>/.test( content );

const {
cliOutput,
cliStatus,
cliTime,
isRunning,
handleExecute,
setCliOutput,
setCliStatus,
setCliTime,
} = useExecuteWPCLI( content, projectPath, updateMessage, messageId );

useEffect( () => {
if ( blocks ) {
const block = blocks?.find( ( block ) => block.codeBlockContent === content );
if ( block ) {
setCliOutput( block?.cliOutput ? stripAnsi( block.cliOutput ) : null );
setCliStatus( block?.cliStatus ?? null );
setCliTime( block?.cliTime ?? null );
}
}
}, [ cliOutput, content, setCliOutput, setCliStatus, setCliTime ] );

const { children, className } = props;
const match = /language-(\w+)/.exec( className || '' );
const { node, ...propsSansNode } = props;
return match ? (
<>
<div className="p-3">
<code className={ className } { ...propsSansNode }>
{ children }
</code>
</div>
<div className="p-3 pt-1 flex justify-start items-center">
<CopyTextButton
text={ content }
label={ __( 'Copy' ) }
copyConfirmation={ __( 'Copied!' ) }
showText={ true }
variant="outlined"
className="h-auto mr-2 !px-2.5 py-0.5 !p-[6px] font-sans select-none"
iconSize={ 16 }
></CopyTextButton>
{ containsWPCommand && containsSingleWPCommand && ! containsAngleBrackets && (
<Button
icon={ <ExecuteIcon /> }
onClick={ handleExecute }
disabled={ isRunning }
variant="outlined"
className="h-auto mr-2 !px-2.5 py-0.5 font-sans select-none"
>
{ cliOutput ? __( 'Run again' ) : __( 'Run' ) }
</Button>
) }
</div>
{ isRunning && (
<div className="p-3 flex justify-start items-center bg-[#2D3337] text-white">
<Spinner className="!text-white [&>circle]:stroke-a8c-gray-60" />
<span className="ml-2 font-sans">{ __( 'Running...' ) }</span>
</div>
) }
{ ! isRunning && cliOutput && cliStatus && (
<InlineCLI output={ cliOutput } status={ cliStatus } time={ cliTime } />
) }
</>
) : (
<code className={ className } { ...propsSansNode }>
{ children }
</code>
);
};

return (
<div
className={ cx(
Expand All @@ -173,7 +69,16 @@ export const ChatMessage = ( {
{ typeof children === 'string' ? (
<div className="assistant-markdown">
<Markdown
components={ { a: Anchor, code: CodeBlock, img: () => null } }
components={ {
a: Anchor,
code: createCodeComponent( {
blocks,
messageId,
projectPath,
updateMessage,
} ),
img: () => null,
} }
remarkPlugins={ [ remarkGfm ] }
>
{ children }
Expand Down
Loading
Loading