import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import ClipboardJS from 'clipboard'; import { axe } from 'jest-axe'; import { Icon } from '@leafygreen-ui/icon'; import { IconButton } from '@leafygreen-ui/icon-button'; import { Context, jest as Jest } from '@leafygreen-ui/testing-lib'; import { Panel } from '../Panel'; import { getTestUtils } from '../testing'; import { languageOptions, renderCode, renderCodeWithLanguageSwitcher, } from '../testing/Code.testutils'; import Code from './Code'; import { hasMultipleLines } from './utils'; const customActionButtons = [ {}} aria-label="label" key="1" data-testid="lg-code-icon_button" > , , , ]; jest.mock('clipboard', () => { const ClipboardJSOriginal = jest.requireActual('clipboard'); // Return a mock that preserves the class and mocks `isSupported` return class ClipboardJSMock extends ClipboardJSOriginal { static isSupported = jest.fn(() => true); // Mock isSupported }; }); describe('packages/Code', () => { // https://stackoverflow.com/a/69574825/13156339 Object.defineProperty(navigator, 'clipboard', { value: { // Provide mock implementation writeText: jest.fn().mockReturnValueOnce(Promise.resolve()), }, }); describe('a11y', () => { test('does not have basic accessibility violations', async () => { const { container } = renderCode(); const results = await axe(container); expect(results).toHaveNoViolations(); }); describe('copy button', () => { test('announces copied to screenreaders when content is copied without a panel', () => { renderCode(); const copyIcon = screen.getByRole('button'); userEvent.click(copyIcon); expect(screen.getByRole('alert')).toBeInTheDocument(); }); test('announces copied to screenreaders when content is copied in the panel', () => { renderCode({ panel: }); const copyIcon = screen.getByRole('button'); userEvent.click(copyIcon); expect(screen.getByRole('alert')).toBeInTheDocument(); }); }); }); describe('hasMultipleLines()', () => { test('when passed a single line without preceding and subsequent line breaks, returns "false"', () => { const codeExample = `Example`; expect(hasMultipleLines(codeExample)).toBeFalsy(); }); test('when passed a single line with preceding and subsequent line breaks, returns "false"', () => { const codeExample = `\nExample\n`; expect(hasMultipleLines(codeExample)).toBeFalsy(); }); test('when passed a multiple lines without preceding and subsequent line breaks, returns "true"', () => { const codeExample = `Example\nstring`; expect(hasMultipleLines(codeExample)).toBeTruthy(); }); test('when passed multiple lines with preceding and subsequent line breaks, returns "true"', () => { const codeExample = `\nExample\nstring\n`; expect(hasMultipleLines(codeExample)).toBeTruthy(); }); }); describe('isLoading prop', () => { describe('when isLoading is true', () => { test('renders a skeleton', () => { const { getByTestId } = renderCode({ isLoading: true }); expect(getByTestId('lg-code-skeleton')).toBeInTheDocument(); }); test('does not render a pre tag', () => { const { queryByTestId } = renderCode({ isLoading: true }); expect(queryByTestId('lg-code-pre')).toBeNull(); }); describe('with panel slot', () => { test('language switcher is disabled', () => { const { getCopyButtonUtils } = renderCode({ isLoading: true, language: languageOptions[0].displayName, panel: ( {}} languageOptions={languageOptions} /> ), }); expect(getCopyButtonUtils().getButton()).toBeInTheDocument(); expect(getCopyButtonUtils().isDisabled()).toBe(true); }); test('copy button is disabled', () => { const { getCopyButtonUtils } = renderCode({ isLoading: true, language: languageOptions[0].displayName, panel: , }); expect(getCopyButtonUtils().getButton()).toBeInTheDocument(); expect(getCopyButtonUtils().isDisabled()).toBe(true); }); }); describe('without panel slot', () => { test('throws error and copy button is not rendered', () => { try { const { getCopyButtonUtils } = Context.within( Jest.spyContext(ClipboardJS, 'isSupported'), spy => { spy.mockReturnValue(true); return renderCode({ isLoading: true, }); }, ); const _ = getCopyButtonUtils().getButton(); } catch (error) { expect(error).toBeInstanceOf(Error); expect(error).toHaveProperty( 'message', expect.stringMatching( /Unable to find an element by: \[data-lgid="lg-code-copy_button"\]/, ), ); } }); }); }); describe('when isLoading is false', () => { test('does not render a skeleton', () => { const { queryByTestId } = renderCode({ isLoading: false }); expect(queryByTestId('lg-code-skeleton')).toBeNull(); }); test('renders a pre tag', () => { const { getByTestId } = renderCode({ isLoading: false }); expect(getByTestId('lg-code-pre')).toBeInTheDocument(); }); describe('with panel slot', () => { test('language switcher is enabled', () => { const { getLanguageSwitcherUtils } = renderCode({ isLoading: false, language: languageOptions[0].displayName, panel: ( {}} languageOptions={languageOptions} /> ), }); expect(getLanguageSwitcherUtils().getInput()).toBeInTheDocument(); expect(getLanguageSwitcherUtils().isDisabled()).toBe(false); }); test('copy button is enabled', () => { const { getCopyButtonUtils } = Context.within( Jest.spyContext(ClipboardJS, 'isSupported'), spy => { spy.mockReturnValue(true); return renderCode({ isLoading: false, language: languageOptions[0].displayName, panel: ( {}} languageOptions={languageOptions} /> ), }); }, ); expect(getCopyButtonUtils().getButton()).toBeInTheDocument(); expect(getCopyButtonUtils().isDisabled()).toBe(false); }); }); describe('without panel slot', () => { test('copy button is enabled', () => { const { getCopyButtonUtils } = Context.within( Jest.spyContext(ClipboardJS, 'isSupported'), spy => { spy.mockReturnValue(true); return renderCode({ isLoading: false, }); }, ); expect(getCopyButtonUtils().getButton()).toBeInTheDocument(); expect(getCopyButtonUtils().isDisabled()).toBe(false); }); }); }); }); describe('Without panel slot', () => { test('does not render a panel', () => { const { queryPanel } = renderCode(); expect(queryPanel()).toBeNull(); }); describe('renders a copy button', () => { test('with default value of hover', () => { const { getCopyButtonUtils } = Context.within( Jest.spyContext(ClipboardJS, 'isSupported'), spy => { spy.mockReturnValue(true); return renderCode(); }, ); expect(getCopyButtonUtils().getButton()).not.toBeNull(); }); test('when copyButtonAppearance is persist', () => { const { getCopyButtonUtils } = Context.within( Jest.spyContext(ClipboardJS, 'isSupported'), spy => { spy.mockReturnValue(true); return renderCode({ copyButtonAppearance: 'persist' }); }, ); expect(getCopyButtonUtils().getButton()).not.toBeNull(); }); test('when copyButtonAppearance is hover', () => { const { getCopyButtonUtils } = Context.within( Jest.spyContext(ClipboardJS, 'isSupported'), spy => { spy.mockReturnValue(true); return renderCode({ copyButtonAppearance: 'hover' }); }, ); expect(getCopyButtonUtils().getButton()).not.toBeNull(); }); }); describe('throws error', () => { test('when copyButtonAppearance is none', () => { try { const { getCopyButtonUtils } = renderCode({ copyButtonAppearance: 'none', }); const _ = getCopyButtonUtils().getButton(); } catch (error) { expect(error).toBeInstanceOf(Error); expect(error).toHaveProperty( 'message', expect.stringMatching( /Unable to find an element by: \[data-lgid="lg-code-copy_button"\]/, ), ); } }); }); }); describe('With panel slot', () => { describe('renders', () => { test('panel when no props are passed', () => { const { queryPanel } = renderCode({ panel: }); expect(queryPanel()).toBeDefined(); }); test('panel when onCopy is passed', () => { const { queryPanel } = renderCode({ panel: {}} />, }); expect(queryPanel()).toBeDefined(); }); }); describe('language switcher', () => { test('renders when languageOptions, language, and onChange are defined', () => { const { getLanguageSwitcherUtils } = renderCode({ language: languageOptions[0].displayName, panel: ( {}} languageOptions={languageOptions} /> ), }); expect(getLanguageSwitcherUtils().getInput()).toBeDefined(); }); test('does not render and throws if the languageOptions is not defined', () => { try { const { getLanguageSwitcherUtils } = renderCode({ language: languageOptions[0].displayName, // @ts-expect-error panel: {}} />, }); const _ = getLanguageSwitcherUtils().getInput(); } catch (error) { expect(error).toBeInstanceOf(Error); expect(error).toHaveProperty( 'message', expect.stringMatching( /Unable to find an element by: \[data-lgid="lg-code-select"\]/, ), ); } }); test('does not render and throws if onChange is not defined', () => { try { const { getLanguageSwitcherUtils } = renderCode({ language: languageOptions[0].displayName, // @ts-expect-error - onChange is not defined panel: , }); const _ = getLanguageSwitcherUtils().getInput(); } catch (error) { expect(error).toBeInstanceOf(Error); expect(error).toHaveProperty( 'message', expect.stringMatching( /Unable to find an element by: \[data-lgid="lg-code-select"\]/, ), ); } }); test('does not render and throws if languageOptions is an empty array', () => { try { const { getLanguageSwitcherUtils } = renderCode({ language: languageOptions[0].displayName, panel: {}} languageOptions={[]} />, }); const _ = getLanguageSwitcherUtils().getInput(); } catch (error) { expect(error).toBeInstanceOf(Error); expect(error).toHaveProperty( 'message', expect.stringMatching( /Unable to find an element by: \[data-lgid="lg-code-select"\]/, ), ); } }); test('does not render and throws if langauage is a string', () => { try { const { getLanguageSwitcherUtils } = renderCode({ language: 'javascript', panel: {}} languageOptions={[]} />, }); const _ = getLanguageSwitcherUtils().getInput(); } catch (error) { expect(error).toBeInstanceOf(Error); expect(error).toHaveProperty( 'message', expect.stringMatching( /Unable to find an element by: \[data-lgid="lg-code-select"\]/, ), ); } }); test('throws an error if language is not in languageOptions', () => { try { renderCode({ language: 'Testing', panel: {}} languageOptions={[]} />, }); } catch (error) { expect(error).toBeInstanceOf(Error); expect(error).toHaveProperty( 'message', expect.stringMatching(/Unknown language: "Testing"/), ); } }); describe('when the language and displayName are the same', () => { test('renders the language picker', () => { const { getLanguageSwitcherUtils } = renderCode({ language: languageOptions[0].displayName, panel: ( {}} languageOptions={languageOptions} /> ), }); expect(getLanguageSwitcherUtils().getInput()).toBeDefined(); }); test('the language pickers display the displayName', () => { const { getLanguageSwitcherUtils } = renderCode({ language: languageOptions[0].displayName, panel: ( {}} languageOptions={languageOptions} /> ), }); expect(getLanguageSwitcherUtils().getInput()).toHaveTextContent( 'JavaScript', ); }); test('sets the correct language', () => { const { getLanguage } = renderCode({ language: languageOptions[0].displayName, panel: ( {}} languageOptions={languageOptions} /> ), }); expect(getLanguage()).toBe('JavaScript'); }); }); describe('when the language and displayName are different', () => { test('renders the language picker', () => { const { getLanguageSwitcherUtils } = renderCode({ language: languageOptions[2].language, // language is shell. displayName is macOS panel: ( {}} languageOptions={languageOptions} /> ), }); expect(getLanguageSwitcherUtils().getInput()).toBeDefined(); }); test('the language pickers displays the displayName', () => { const { getLanguageSwitcherUtils } = renderCode({ language: languageOptions[2].language, // language is shell. displayName is macOS panel: ( {}} languageOptions={languageOptions} /> ), }); expect(getLanguageSwitcherUtils().getInput()).toHaveTextContent( 'macOS', ); }); test('sets the correct language', () => { const { getLanguage } = renderCode({ language: languageOptions[2].language, // language is shell. displayName is macOS panel: ( {}} languageOptions={languageOptions} /> ), }); expect(getLanguage()).toBe('shell'); }); }); }); describe('custom action buttons', () => { test('do not render if showCustomActionButtons is false', () => { const { queryAllByTestId } = renderCode({ panel: ( ), }); expect(queryAllByTestId('lg-code-icon_button')).toHaveLength(0); }); test('do not render if customActionButtons is an empty array', () => { const { queryAllByTestId } = renderCode({ panel: , }); expect(queryAllByTestId('lg-code-icon_button')).toHaveLength(0); }); test('renders when custom action buttons are present and showCustomActionButtons is true', () => { const { queryPanel } = renderCode({ panel: ( ), }); expect(queryPanel()).toBeDefined(); }); test('only renders IconButton elements', () => { const { queryAllByTestId } = renderCode({ panel: ( ), }); expect(queryAllByTestId('lg-code-icon_button')).toHaveLength(2); }); }); }); describe('when rendered as a language switcher', () => { let offsetParentSpy: jest.SpyInstance; beforeAll(() => { offsetParentSpy = jest.spyOn( HTMLElement.prototype, 'offsetParent', 'get', ); // JSDOM doesn't implement `HTMLElement.prototype.offsetParent`, so this // falls back to the parent element since it doesn't matter for these tests. offsetParentSpy.mockImplementation(function (this: HTMLElement) { return this.parentElement; }); }); afterAll(() => { if (offsetParentSpy.mock.calls.length === 0) { // throw Error('`HTMLElement.prototype.offsetParent` was never called'); } offsetParentSpy.mockRestore(); }); test('a collapsed select is rendered, with an active state based on the language prop', () => { const { getLanguageSwitcherUtils } = renderCodeWithLanguageSwitcher({}); expect(getLanguageSwitcherUtils().getInput()).toBeInTheDocument(); expect(getLanguageSwitcherUtils().getInput()).toHaveTextContent( 'JavaScript', ); }); test('clicking the collapsed select menu button opens a select', () => { const { getLanguageSwitcherUtils } = renderCodeWithLanguageSwitcher({}); const trigger = getLanguageSwitcherUtils().getInput(); userEvent.click(trigger!); expect(getLanguageSwitcherUtils().getOptions()).toHaveLength(3); }); test('options displayed in select are based on the languageOptions prop', () => { const { getLanguageSwitcherUtils } = renderCodeWithLanguageSwitcher({}); const { getInput, getOptionByValue } = getLanguageSwitcherUtils(); const trigger = getInput(); userEvent.click(trigger!); ['JavaScript', 'Python'].forEach(lang => { expect(getOptionByValue(lang)).toBeInTheDocument(); }); }); test('onChange prop gets called when new language is selected', () => { const onChange = jest.fn(); const { getLanguageSwitcherUtils } = renderCodeWithLanguageSwitcher({ props: { onChange, }, }); const { getOptionByValue, getInput } = getLanguageSwitcherUtils(); const trigger = getInput(); userEvent.click(trigger!); userEvent.click(getOptionByValue('Python')!); expect(onChange).toHaveBeenCalled(); }); test('onChange prop is called with an object that represents the newly selected language when called', () => { const onChange = jest.fn(); const { getLanguageSwitcherUtils } = renderCodeWithLanguageSwitcher({ props: { onChange, }, }); const { getOptionByValue, getInput } = getLanguageSwitcherUtils(); const trigger = getInput(); userEvent.click(trigger!); userEvent.click(getOptionByValue('Python')!); expect(onChange).toHaveBeenCalledWith({ displayName: 'Python', language: 'python', }); }); }); describe('when expandable', () => { const getCodeSnippet = (lineCount: number) => Array.from( { length: lineCount }, (_, i) => `const greeting${i} = "Hello, world! ${i}";`, ).join('\n'); const defaultCollapsedLines = 5; test(`returns null and shows no expand button when <= ${defaultCollapsedLines} lines of code (default)`, () => { render( {getCodeSnippet(defaultCollapsedLines - 1)} , ); const { getExpandButtonUtils } = getTestUtils(); expect(getExpandButtonUtils().queryButton()).toBeNull(); }); test(`shows expand button when > ${defaultCollapsedLines} lines of code (default)`, () => { render( {getCodeSnippet(defaultCollapsedLines + 1)} , ); const { getExpandButtonUtils } = getTestUtils(); expect(getExpandButtonUtils().getButton()).toBeInTheDocument(); }); test('shows correct number of lines of code on expand button', () => { const lineCount = defaultCollapsedLines + 1; render( {getCodeSnippet(lineCount)} , ); const { getExpandButtonUtils } = getTestUtils(); const actionButton = getExpandButtonUtils().getButton(); expect(actionButton).toHaveTextContent( `Click to expand (${lineCount} lines)`, ); }); test('shows collapse button when expand button is clicked', () => { render( {getCodeSnippet(defaultCollapsedLines + 1)} , ); const { getExpandButtonUtils, getIsExpanded } = getTestUtils(); const actionButton = getExpandButtonUtils().getButton(); userEvent.click(actionButton!); expect(actionButton).toHaveTextContent('Click to collapse'); expect(getIsExpanded()).toBe(true); }); test('shows expand button again when collapse button is clicked', () => { const lineCount = defaultCollapsedLines + 1; render( {getCodeSnippet(lineCount)} , ); const { getExpandButtonUtils } = getTestUtils(); const actionButton = getExpandButtonUtils().getButton(); userEvent.click(actionButton!); // Expand userEvent.click(actionButton!); // Collapse expect(actionButton).toHaveTextContent( `Click to expand (${lineCount} lines)`, ); }); describe('with custom collapsedLines prop', () => { test('shows no expand button when lines <= collapsedLines', () => { const customCollapsedLines = 10; render( {getCodeSnippet(customCollapsedLines)} , ); const { getExpandButtonUtils } = getTestUtils(); expect(getExpandButtonUtils().queryButton()).toBeNull(); }); test('shows expand button when lines > collapsedLines', () => { const customCollapsedLines = 3; render( {getCodeSnippet(customCollapsedLines + 1)} , ); const { getExpandButtonUtils } = getTestUtils(); expect(getExpandButtonUtils().getButton()).toBeInTheDocument(); }); test('shows correct number of lines when using custom collapsedLines', () => { const customCollapsedLines = 8; const lineCount = customCollapsedLines + 5; render( {getCodeSnippet(lineCount)} , ); const { getExpandButtonUtils } = getTestUtils(); const actionButton = getExpandButtonUtils().getButton(); expect(actionButton).toHaveTextContent( `Click to expand (${lineCount} lines)`, ); }); test('expand/collapse functionality works with custom collapsedLines', () => { const customCollapsedLines = 7; const lineCount = customCollapsedLines + 2; render( {getCodeSnippet(lineCount)} , ); const { getExpandButtonUtils, getIsExpanded } = getTestUtils(); const actionButton = getExpandButtonUtils().getButton(); // Initially collapsed expect(getIsExpanded()).toBe(false); expect(actionButton).toHaveTextContent( `Click to expand (${lineCount} lines)`, ); // Click to expand userEvent.click(actionButton!); expect(getIsExpanded()).toBe(true); expect(actionButton).toHaveTextContent('Click to collapse'); // Click to collapse userEvent.click(actionButton!); expect(getIsExpanded()).toBe(false); expect(actionButton).toHaveTextContent( `Click to expand (${lineCount} lines)`, ); }); }); }); // eslint-disable-next-line jest/no-disabled-tests test.skip('types behave as expected', () => { <> snippet snippet {/* @ts-expect-error - missing language prop */} snippet {/* @ts-expect-error - missing children */} {}} darkMode={true} panel={} > snippet {/* @ts-expect-error - cannot pass both panel and copyButtonAppearance */} {}} darkMode={true} panel={} copyButtonAppearance="hover" > snippet {}} darkMode={true} copyButtonAppearance="hover" > snippet {}} darkMode={true} // @ts-expect-error - onChange prop is missing on panel={} > snippet {}} darkMode={true} // @ts-expect-error - languageOptions prop is missing on panel={ {}} />} > snippet {}} darkMode={true} panel={ {}} languageOptions={[]} showCustomActionButtons customActionButtons={[]} title="Title" /> } > snippet ; }); });