diff --git a/jest.polyfills.js b/jest.polyfills.js index a9ee2ed678..a705b3e6a6 100644 --- a/jest.polyfills.js +++ b/jest.polyfills.js @@ -3,6 +3,13 @@ const { TextEncoder, TextDecoder } = require('node:util'); Reflect.set(globalThis, 'TextEncoder', TextEncoder); Reflect.set(globalThis, 'TextDecoder', TextDecoder); +// Polyfill setImmediate and clearImmediate for undici +// undici uses these Node.js-specific timers internally, but they don't exist in jsdom +const { setImmediate: nodeSetImmediate, clearImmediate: nodeClearImmediate } = require('node:timers'); + +Reflect.set(globalThis, 'setImmediate', nodeSetImmediate); +Reflect.set(globalThis, 'clearImmediate', nodeClearImmediate); + const { Blob } = require('node:buffer'); const { fetch, Request, Response, Headers, FormData } = require('undici'); diff --git a/src/components/Layout/LeftSidebar.tsx b/src/components/Layout/LeftSidebar.tsx index 1bc7d075ac..0574666f77 100644 --- a/src/components/Layout/LeftSidebar.tsx +++ b/src/components/Layout/LeftSidebar.tsx @@ -199,7 +199,7 @@ const LeftSidebar = ({ className, inHeader = false }: LeftSidebarProps) => { type="multiple" value={openProducts} className={cn( - 'bg-neutral-000 dark:bg-neutral-1300 overflow-y-auto', + 'bg-neutral-000 dark:bg-neutral-1300 overflow-x-hidden overflow-y-auto', inHeader ? 'w-full h-[calc(100dvh-64px-128px)]' : [ diff --git a/src/components/Layout/MDXWrapper.test.tsx b/src/components/Layout/MDXWrapper.test.tsx index edb268741d..27b509bb59 100644 --- a/src/components/Layout/MDXWrapper.test.tsx +++ b/src/components/Layout/MDXWrapper.test.tsx @@ -1,6 +1,6 @@ -import React, { ReactNode } from 'react'; +import { ReactNode } from 'react'; import { WindowLocation } from '@reach/router'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { Helmet } from 'react-helmet'; import If from './mdx/If'; @@ -66,7 +66,36 @@ jest.mock('@ably/ui/core/Code', () => { }; }); +// Mock Radix UI Tooltip to avoid act() warnings from async state updates +jest.mock('@radix-ui/react-tooltip', () => ({ + Provider: ({ children }: { children: ReactNode }) =>
{children}
, + Root: ({ children }: { children: ReactNode }) =>
{children}
, + Trigger: ({ children, asChild }: { children: ReactNode; asChild?: boolean }) => + asChild ? children :
{children}
, + Portal: ({ children }: { children: ReactNode }) =>
{children}
, + Content: ({ children }: { children: ReactNode }) =>
{children}
, +})); + describe('MDX component integration', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Suppress console warnings for MSW unhandled .md requests + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + const message = String(args[0]); + if (message.includes('[MSW]') && message.includes('.md')) { + return; + } + originalWarn.apply(console, args); + }; + }); + + afterEach(() => { + // Restore console.warn and clear timers + jest.restoreAllMocks(); + jest.clearAllTimers(); + }); + it('renders basic content correctly', () => { render(
@@ -276,13 +305,24 @@ describe('MDXWrapper structured data', () => { template: 'mdx' as const, }, }); + + // Mock fetch to prevent async issues with jsdom teardown + global.fetch = jest.fn(() => Promise.reject(new Error('Markdown not available'))) as jest.Mock; + + // Suppress console.error for expected fetch failures + jest.spyOn(console, 'error').mockImplementation((message) => { + if (typeof message === 'string' && message.includes('Failed to fetch markdown')) { + return; + } + }); }); afterEach(() => { jest.clearAllMocks(); + jest.restoreAllMocks(); }); - it('does not generate structured data when only one language is present', () => { + it('does not generate structured data when only one language is present', async () => { render( @@ -291,13 +331,18 @@ describe('MDXWrapper structured data', () => { , ); + // Wait for any async operations to settle + await waitFor(() => { + expect(screen.getByText('Test content')).toBeInTheDocument(); + }); + const helmet = Helmet.peek(); const jsonLdScript = helmet.scriptTags?.find((tag: { type?: string }) => tag.type === 'application/ld+json'); expect(jsonLdScript).toBeUndefined(); }); - it('generates TechArticle structured data with multiple languages', () => { + it('generates TechArticle structured data with multiple languages', async () => { mockUseLayoutContext.mockReturnValue({ activePage: { product: 'pubsub', @@ -320,6 +365,11 @@ describe('MDXWrapper structured data', () => { , ); + // Wait for any async operations to settle + await waitFor(() => { + expect(screen.getByText('Test content')).toBeInTheDocument(); + }); + const helmet = Helmet.peek(); const jsonLdScript = helmet.scriptTags?.find((tag: { type?: string }) => tag.type === 'application/ld+json'); diff --git a/src/components/Layout/mdx/PageHeader.test.tsx b/src/components/Layout/mdx/PageHeader.test.tsx new file mode 100644 index 0000000000..20ded4a350 --- /dev/null +++ b/src/components/Layout/mdx/PageHeader.test.tsx @@ -0,0 +1,151 @@ +import React, { ReactNode } from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { PageHeader } from './PageHeader'; + +const mockUseLayoutContext = jest.fn(() => ({ + activePage: { + language: 'javascript', + languages: ['javascript'], + product: 'pubsub', + page: { + name: 'Test Page', + link: '/docs/test-page', + }, + tree: [], + template: 'mdx' as const, + }, +})); + +// Mock the layout context +jest.mock('src/contexts/layout-context', () => ({ + useLayoutContext: () => mockUseLayoutContext(), +})); + +// Mock useLocation from @reach/router +jest.mock('@reach/router', () => ({ + useLocation: () => ({ pathname: '/docs/test-page' }), +})); + +// Mock Radix UI Tooltip to avoid act() warnings from async state updates +jest.mock('@radix-ui/react-tooltip', () => ({ + Provider: ({ children }: { children: ReactNode }) =>
{children}
, + Root: ({ children }: { children: ReactNode }) =>
{children}
, + Trigger: ({ children, asChild }: { children: ReactNode; asChild?: boolean }) => + asChild ? children :
{children}
, + Portal: ({ children }: { children: ReactNode }) =>
{children}
, + Content: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +// Mock Icon component +jest.mock('@ably/ui/core/Icon', () => { + return { + __esModule: true, + default: ({ name, size }: { name: string; size: string }) => ( + + {name} + + ), + }; +}); + +// Mock track function +jest.mock('@ably/ui/core/insights', () => ({ + track: jest.fn(), +})); + +// Mock LanguageSelector to avoid its complexity +jest.mock('../LanguageSelector', () => ({ + LanguageSelector: () =>
Language Selector
, +})); + +describe('PageHeader Markdown buttons', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllTimers(); + }); + + it('shows Markdown copy and view buttons when markdown content is available', async () => { + const mockMarkdownContent = '# Mock markdown content\n\nThis is a test.'; + + // Mock successful fetch response + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + headers: { + get: (name: string) => (name === 'Content-Type' ? 'text/markdown' : null), + }, + text: () => Promise.resolve(mockMarkdownContent), + } as Response), + ); + + // Mock clipboard API + const mockWriteText = jest.fn(); + Object.assign(navigator, { + clipboard: { + writeText: mockWriteText, + }, + }); + + // Use fake timers to control the setTimeout in resetCopyTooltip + jest.useFakeTimers(); + + render(); + + // Wait for the fetch to complete and state to update + await screen.findByText('Markdown'); + + // Check that the Copy button is present + const copyButton = screen.getByLabelText('Copy Markdown'); + expect(copyButton).toBeInTheDocument(); + + // Check that the View link is present + const viewLink = screen.getByRole('link', { name: /icon-gui-eye-outline/i }); + expect(viewLink).toBeInTheDocument(); + expect(viewLink).toHaveAttribute('href', '/docs/test-page.md'); + + // Click the copy button and verify clipboard interaction + act(() => { + fireEvent.click(copyButton); + }); + + expect(mockWriteText).toHaveBeenCalledWith(mockMarkdownContent); + expect(mockWriteText).toHaveBeenCalledTimes(1); + + // Clean up timers + act(() => { + jest.runOnlyPendingTimers(); + }); + jest.useRealTimers(); + }); + + it('does not show Markdown buttons when markdown content is null', async () => { + // Suppress console.error for this test since we're testing a failure case + const originalError = console.error; + console.error = jest.fn(); + + // Mock failed fetch response + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 404, + } as Response), + ); + + render(); + + // Wait a bit for any async updates + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Check that the Markdown section is not present + expect(screen.queryByText('Markdown')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Copy Markdown')).not.toBeInTheDocument(); + + // Restore console.error + console.error = originalError; + }); +}); diff --git a/src/components/Layout/mdx/PageHeader.tsx b/src/components/Layout/mdx/PageHeader.tsx index e07fffe386..cf03b1d78d 100644 --- a/src/components/Layout/mdx/PageHeader.tsx +++ b/src/components/Layout/mdx/PageHeader.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useLocation } from '@reach/router'; import * as Tooltip from '@radix-ui/react-tooltip'; import cn from '@ably/ui/core/utils/cn'; @@ -23,6 +23,7 @@ export const PageHeader: React.FC = ({ title, intro }) => { const location = useLocation(); const [copyTooltipOpen, setCopyTooltipOpen] = useState(false); const [copyTooltipContent, setCopyTooltipContent] = useState('Copy'); + const [markdownContent, setMarkdownContent] = useState(null); const llmLinks = useMemo(() => { const prompt = `Tell me more about ${product ? productData[product]?.nav.name : 'Ably'}'s '${page.name}' feature from https://ably.com${page.link}${language ? ` for ${languageInfo[language]?.label}` : ''}`; @@ -44,27 +45,70 @@ export const PageHeader: React.FC = ({ title, intro }) => { [activePage.languages, product], ); - const handleCopyMarkdown = useCallback(async () => { - try { - const response = await fetch(`${location.pathname}.md`); + useEffect(() => { + const abortController = new AbortController(); + let isMounted = true; - if (!response.ok) { - throw new Error(`Failed to fetch markdown: ${response.status} ${response.statusText}`); - } + const fetchMarkdown = async () => { + try { + const response = await fetch(`${location.pathname}.md`, { + signal: abortController.signal, + }); + + if (!isMounted) { + return; + } + + if (!response.ok) { + throw new Error(`Failed to fetch markdown: ${response.status} ${response.statusText}`); + } + + const contentType = response.headers.get('Content-Type'); + if (!contentType || !contentType.includes('text/markdown')) { + throw new Error(`Invalid content type: expected text/markdown, got ${contentType}`); + } - const contentType = response.headers.get('Content-Type'); - if (!contentType || !contentType.includes('text/markdown')) { - throw new Error(`Invalid content type: expected text/markdown, got ${contentType}`); + const content = await response.text(); + + // Only update state if component is still mounted + if (isMounted) { + setMarkdownContent(content); + } + } catch (error) { + // Ignore all errors if component is unmounted or if it's an abort error + if (!isMounted || (error instanceof Error && error.name === 'AbortError')) { + return; + } + console.error('Failed to fetch markdown:', error); + setMarkdownContent(null); } + }; + + fetchMarkdown(); + + return () => { + isMounted = false; + abortController.abort(); + }; + }, [location.pathname]); + + const resetCopyTooltip = useCallback(() => { + setCopyTooltipOpen(true); + setTimeout(() => { + setCopyTooltipOpen(false); + setTimeout(() => setCopyTooltipContent('Copy'), 150); + }, 2000); + }, []); - const content = await response.text(); - await navigator.clipboard.writeText(content); + const handleCopyMarkdown = () => { + if (!markdownContent) { + return; + } + + try { + navigator.clipboard.writeText(markdownContent); setCopyTooltipContent('Copied!'); - setCopyTooltipOpen(true); - setTimeout(() => { - setCopyTooltipOpen(false); - setTimeout(() => setCopyTooltipContent('Copy'), 150); - }, 2000); + resetCopyTooltip(); track('markdown_copy_link_clicked', { location: location.pathname, @@ -72,13 +116,9 @@ export const PageHeader: React.FC = ({ title, intro }) => { } catch (error) { console.error('Failed to copy markdown:', error); setCopyTooltipContent('Error!'); - setCopyTooltipOpen(true); - setTimeout(() => { - setCopyTooltipOpen(false); - setTimeout(() => setCopyTooltipContent('Copy'), 150); - }, 2000); + resetCopyTooltip(); } - }, [location.pathname]); + }; return (
@@ -97,39 +137,45 @@ export const PageHeader: React.FC = ({ title, intro }) => { )} -
- Markdown - - - - - - {copyTooltipContent} - - - - - { - track('markdown_preview_link_clicked', { - location: location.pathname, - }); - }} - > - - - - - View - - -
+ {markdownContent && ( +
+ Markdown + + + + + + {copyTooltipContent} + + + + + { + track('markdown_preview_link_clicked', { + location: location.pathname, + }); + }} + > + + + + + View + + +
+ )}
Open in {llmLinks.map(({ model, label, icon, link }) => ( diff --git a/src/components/Layout/utils/styles.ts b/src/components/Layout/utils/styles.ts index 40b060eac2..7e1ffcb1e4 100644 --- a/src/components/Layout/utils/styles.ts +++ b/src/components/Layout/utils/styles.ts @@ -13,6 +13,6 @@ export const iconButtonClassName = cn(secondaryButtonClassName, 'w-9 p-0'); export const interactiveButtonClassName = cn( 'flex items-center justify-center bg-neutral-000 dark:bg-neutral-1300 hover:bg-neutral-100 dark:hover:bg-neutral-1200 active:bg-neutral-200 dark:active:bg-neutral-1100 cursor-pointer p-1.5', - 'text-neutral-900 dark:text-neutral-400 hover:text-neutral-1300 dark:hover:text-neutral-000', + 'text-neutral-900 dark:text-neutral-400 hover:text-neutral-1300 dark:hover:text-neutral-000 disabled:text-gui-unavailable dark:disabled:text-gui-unavailable-dark disabled:pointer-events-none', 'focus-base rounded-lg transition-colors', );