diff --git a/src/components/editor/ExportBar.tsx b/src/components/editor/ExportBar.tsx index 0b14bac..0bfc78a 100644 --- a/src/components/editor/ExportBar.tsx +++ b/src/components/editor/ExportBar.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { generateMetaTags } from '@/lib/meta-tag-generator'; +import { copyToClipboard as copyTextToClipboard } from '@/lib/clipboard'; import { containsLocalImage, svgToPngBlob } from './export-utils'; interface ExportBarProps { @@ -74,22 +75,13 @@ export function ExportBar({ }, []); const copyToClipboard = useCallback(async (text: string, label: string) => { - try { - await navigator.clipboard.writeText(text); - setCopied(label); - window.showToast?.(label === 'url' ? 'URL copied to clipboard!' : 'Meta tags copied!', 'success'); - setTimeout(() => setCopied(null), 2000); - } catch { - // Fallback - const ta = document.createElement('textarea'); - ta.value = text; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); + const success = await copyTextToClipboard(text); + if (success) { setCopied(label); window.showToast?.(label === 'url' ? 'URL copied to clipboard!' : 'Meta tags copied!', 'success'); setTimeout(() => setCopied(null), 2000); + } else { + window.showToast?.(label === 'url' ? 'Could not copy URL' : 'Could not copy meta tags', 'error'); } }, []); diff --git a/src/components/preview/FixItButton.tsx b/src/components/preview/FixItButton.tsx index 7662875..d783779 100644 --- a/src/components/preview/FixItButton.tsx +++ b/src/components/preview/FixItButton.tsx @@ -1,5 +1,6 @@ import type { MetaTags } from '@/lib/meta-fetcher'; import { generateMetaTags } from '@/lib/meta-tag-generator'; +import { copyToClipboard } from '@/lib/clipboard'; interface FixItButtonProps { meta: MetaTags; @@ -26,21 +27,11 @@ export function FixItButton({ meta }: FixItButtonProps) { }); const handleCopy = async () => { - try { - await navigator.clipboard.writeText(tags); + const success = await copyToClipboard(tags); + if (success) { window.showToast?.('Recommended meta tags copied!', 'success'); - } catch { - try { - const ta = document.createElement('textarea'); - ta.value = tags; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - window.showToast?.('Recommended meta tags copied!', 'success'); - } catch { - window.showToast?.('Could not copy meta tags', 'error'); - } + } else { + window.showToast?.('Could not copy meta tags', 'error'); } }; diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts new file mode 100644 index 0000000..0d05e10 --- /dev/null +++ b/src/lib/clipboard.ts @@ -0,0 +1,35 @@ +/** + * Copies text to the clipboard using the modern Clipboard API, + * falling back to document.execCommand('copy') with a temporary textarea + * if the modern API is unavailable or rejects. + * + * Returns true if copying succeeded, false otherwise. + */ +export async function copyToClipboard(text: string): Promise { + if (typeof navigator !== 'undefined' && typeof navigator.clipboard?.writeText === 'function') { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Modern Clipboard API rejected; proceed to fallback. + } + } + + if (typeof document !== 'undefined' && document.body) { + let successful = false; + const ta = document.createElement('textarea'); + ta.value = text; + document.body.appendChild(ta); + try { + ta.select?.(); + successful = Boolean(document.execCommand?.('copy')); + } catch { + successful = false; + } finally { + document.body.removeChild(ta); + } + return successful; + } + + return false; +} diff --git a/tests/components/fix-it-button.test.ts b/tests/components/fix-it-button.test.ts new file mode 100644 index 0000000..4149ace --- /dev/null +++ b/tests/components/fix-it-button.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { FixItButton } from '@/components/preview/FixItButton'; + +describe('FixItButton copy notification behavior', () => { + let showToastMock: ReturnType; + let mockExecCommand: ReturnType; + let mockAppendChild: ReturnType; + let mockRemoveChild: ReturnType; + let createdTextarea: { value: string; select: ReturnType } | null; + + beforeEach(() => { + createdTextarea = null; + showToastMock = vi.fn(); + mockExecCommand = vi.fn(); + mockAppendChild = vi.fn((el) => el); + mockRemoveChild = vi.fn((el) => el); + + vi.stubGlobal('window', { showToast: showToastMock }); + vi.stubGlobal('navigator', {}); // Clipboard API unavailable by default + vi.stubGlobal('document', { + createElement: vi.fn((tag: string) => { + if (tag === 'textarea') { + createdTextarea = { value: '', select: vi.fn() }; + return createdTextarea; + } + return {}; + }), + body: { + appendChild: mockAppendChild, + removeChild: mockRemoveChild, + }, + execCommand: mockExecCommand, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + function getCopyButton(meta = { ogTitle: 'Test Title', ogDescription: 'Test Desc' }) { + const tree = FixItButton({ meta }); + // Structure: div.fix-it-section -> [h3, p, div.fix-it-actions] + const actionsDiv = tree.props.children[2]; + // actionsDiv children: [a.fix-it-btn-primary, button.fix-it-btn-ghost] + const copyBtn = actionsDiv.props.children[1]; + return copyBtn; + } + + it('shows error notification and never reports success when execCommand returns false', async () => { + mockExecCommand.mockReturnValue(false); + const copyBtn = getCopyButton(); + + await copyBtn.props.onClick(); + + expect(showToastMock).toHaveBeenCalledWith('Could not copy meta tags', 'error'); + expect(showToastMock).not.toHaveBeenCalledWith(expect.anything(), 'success'); + }); + + it('shows error notification and never reports success when execCommand throws', async () => { + mockExecCommand.mockImplementation(() => { + throw new Error('execCommand copy disabled'); + }); + const copyBtn = getCopyButton(); + + await copyBtn.props.onClick(); + + expect(showToastMock).toHaveBeenCalledWith('Could not copy meta tags', 'error'); + expect(showToastMock).not.toHaveBeenCalledWith(expect.anything(), 'success'); + }); + + it('shows success notification only when copy succeeds', async () => { + mockExecCommand.mockReturnValue(true); + const copyBtn = getCopyButton(); + + await copyBtn.props.onClick(); + + expect(showToastMock).toHaveBeenCalledWith('Recommended meta tags copied!', 'success'); + expect(showToastMock).not.toHaveBeenCalledWith(expect.anything(), 'error'); + }); +}); diff --git a/tests/editor/export-bar-copy.test.ts b/tests/editor/export-bar-copy.test.ts new file mode 100644 index 0000000..bfea443 --- /dev/null +++ b/tests/editor/export-bar-copy.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const hooks = vi.hoisted(() => ({ + copied: null as string | null, + setCopied: vi.fn(), + effects: [] as (() => void)[], +})); + +vi.mock('react', () => ({ + default: { + createElement: (type: any, props: any, ...children: any[]) => ({ type, props: { ...props, children } }), + }, + useState: (initial: any) => [initial, hooks.setCopied], + useRef: (current: unknown) => ({ current }), + useCallback: (callback: unknown) => callback, + useEffect: (effect: () => void) => { + hooks.effects.push(effect); + }, +})); + +const { ExportBar } = await import('@/components/editor/ExportBar'); + +describe('ExportBar copy notifications and fallback failure', () => { + let showToastMock: ReturnType; + let mockExecCommand: ReturnType; + let mockAppendChild: ReturnType; + let mockRemoveChild: ReturnType; + let keydownListeners: ((e: any) => void)[]; + let createdTextarea: { value: string; select: ReturnType } | null; + + beforeEach(() => { + createdTextarea = null; + showToastMock = vi.fn(); + mockExecCommand = vi.fn(); + mockAppendChild = vi.fn((el) => el); + mockRemoveChild = vi.fn((el) => el); + keydownListeners = []; + hooks.setCopied.mockClear(); + hooks.effects.length = 0; + + vi.stubGlobal('window', { showToast: showToastMock }); + vi.stubGlobal('navigator', {}); // Modern clipboard unavailable + vi.stubGlobal('document', { + createElement: vi.fn((tag: string) => { + if (tag === 'textarea') { + createdTextarea = { value: '', select: vi.fn() }; + return createdTextarea; + } + return {}; + }), + body: { + appendChild: mockAppendChild, + removeChild: mockRemoveChild, + }, + execCommand: mockExecCommand, + addEventListener: vi.fn((event: string, listener: (e: any) => void) => { + if (event === 'keydown') keydownListeners.push(listener); + }), + removeEventListener: vi.fn(), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + function renderBar() { + const tree = ExportBar({ + apiUrl: 'https://example.com/api/og?title=Test', + downloadUrl: 'https://example.com/api/og?title=Test', + params: { title: 'Test' }, + templateId: 'saas-maintenance', + svg: null, + }); + hooks.effects.forEach((effect) => effect()); + // Structure: div.export-bar -> div.export-buttons -> [downloadBtn, copyUrlBtn, copyMetaBtn] + const buttonsContainer = tree.props.children; + const buttons = buttonsContainer.props.children; + return { + copyUrlBtn: buttons[1], + copyMetaBtn: buttons[2], + }; + } + + it('shows error notification and never reports success when execCommand returns false on Copy Image URL', async () => { + mockExecCommand.mockReturnValue(false); + const { copyUrlBtn } = renderBar(); + + await copyUrlBtn.props.onClick(); + + expect(showToastMock).toHaveBeenCalledWith('Could not copy URL', 'error'); + expect(showToastMock).not.toHaveBeenCalledWith(expect.anything(), 'success'); + expect(hooks.setCopied).not.toHaveBeenCalledWith('url'); + }); + + it('shows error notification and never reports success when execCommand throws on Copy Image URL', async () => { + mockExecCommand.mockImplementation(() => { + throw new Error('execCommand copy disabled'); + }); + const { copyUrlBtn } = renderBar(); + + await copyUrlBtn.props.onClick(); + + expect(showToastMock).toHaveBeenCalledWith('Could not copy URL', 'error'); + expect(showToastMock).not.toHaveBeenCalledWith(expect.anything(), 'success'); + expect(hooks.setCopied).not.toHaveBeenCalledWith('url'); + }); + + it('shows error notification and never reports success when execCommand returns false on Copy Meta Tags', async () => { + mockExecCommand.mockReturnValue(false); + const { copyMetaBtn } = renderBar(); + + await copyMetaBtn.props.onClick(); + + expect(showToastMock).toHaveBeenCalledWith('Could not copy meta tags', 'error'); + expect(showToastMock).not.toHaveBeenCalledWith(expect.anything(), 'success'); + expect(hooks.setCopied).not.toHaveBeenCalledWith('meta'); + }); + + it('shows error notification and never reports success when execCommand throws on Copy Meta Tags', async () => { + mockExecCommand.mockImplementation(() => { + throw new Error('execCommand copy disabled'); + }); + const { copyMetaBtn } = renderBar(); + + await copyMetaBtn.props.onClick(); + + expect(showToastMock).toHaveBeenCalledWith('Could not copy meta tags', 'error'); + expect(showToastMock).not.toHaveBeenCalledWith(expect.anything(), 'success'); + expect(hooks.setCopied).not.toHaveBeenCalledWith('meta'); + }); + + it('handles Ctrl/Cmd+Shift+C keyboard shortcut when fallback fails', async () => { + mockExecCommand.mockReturnValue(false); + renderBar(); + + const event = { + key: 'C', + shiftKey: true, + ctrlKey: true, + metaKey: false, + preventDefault: vi.fn(), + }; + for (const listener of keydownListeners) { + listener(event); + } + await Promise.resolve(); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(showToastMock).toHaveBeenCalledWith('Could not copy URL', 'error'); + expect(showToastMock).not.toHaveBeenCalledWith(expect.anything(), 'success'); + expect(hooks.setCopied).not.toHaveBeenCalledWith('url'); + }); + + it('reports success when copy succeeds', async () => { + mockExecCommand.mockReturnValue(true); + const { copyUrlBtn, copyMetaBtn } = renderBar(); + + await copyUrlBtn.props.onClick(); + expect(showToastMock).toHaveBeenCalledWith('URL copied to clipboard!', 'success'); + expect(hooks.setCopied).toHaveBeenCalledWith('url'); + + showToastMock.mockClear(); + hooks.setCopied.mockClear(); + + await copyMetaBtn.props.onClick(); + expect(showToastMock).toHaveBeenCalledWith('Meta tags copied!', 'success'); + expect(hooks.setCopied).toHaveBeenCalledWith('meta'); + }); +}); diff --git a/tests/lib/clipboard.test.ts b/tests/lib/clipboard.test.ts new file mode 100644 index 0000000..f63caf4 --- /dev/null +++ b/tests/lib/clipboard.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { copyToClipboard } from '@/lib/clipboard'; + +describe('copyToClipboard', () => { + let mockAppendChild: ReturnType; + let mockRemoveChild: ReturnType; + let mockExecCommand: ReturnType; + let mockSelect: ReturnType; + let createdTextarea: { value: string; select: ReturnType } | null; + + beforeEach(() => { + createdTextarea = null; + mockAppendChild = vi.fn((el) => el); + mockRemoveChild = vi.fn((el) => el); + mockExecCommand = vi.fn(); + mockSelect = vi.fn(); + + vi.stubGlobal('document', { + createElement: vi.fn((tag: string) => { + if (tag === 'textarea') { + createdTextarea = { value: '', select: mockSelect }; + return createdTextarea; + } + return {}; + }), + body: { + appendChild: mockAppendChild, + removeChild: mockRemoveChild, + }, + execCommand: mockExecCommand, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + describe('when navigator.clipboard is available and succeeds', () => { + it('uses navigator.clipboard.writeText and does not use fallback', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + + const result = await copyToClipboard('test text'); + + expect(result).toBe(true); + expect(writeText).toHaveBeenCalledWith('test text'); + expect(mockExecCommand).not.toHaveBeenCalled(); + expect(mockAppendChild).not.toHaveBeenCalled(); + }); + }); + + describe('fallback when navigator.clipboard is unavailable', () => { + beforeEach(() => { + vi.stubGlobal('navigator', {}); + }); + + it('returns true when document.execCommand succeeds', async () => { + mockExecCommand.mockReturnValue(true); + + const result = await copyToClipboard('fallback text'); + + expect(result).toBe(true); + expect(createdTextarea?.value).toBe('fallback text'); + expect(mockSelect).toHaveBeenCalled(); + expect(mockExecCommand).toHaveBeenCalledWith('copy'); + expect(mockAppendChild).toHaveBeenCalledWith(createdTextarea); + expect(mockRemoveChild).toHaveBeenCalledWith(createdTextarea); + }); + + it('returns false and cleans up textarea when document.execCommand returns false', async () => { + mockExecCommand.mockReturnValue(false); + + const result = await copyToClipboard('fallback text'); + + expect(result).toBe(false); + expect(mockExecCommand).toHaveBeenCalledWith('copy'); + expect(mockAppendChild).toHaveBeenCalledWith(createdTextarea); + expect(mockRemoveChild).toHaveBeenCalledWith(createdTextarea); + }); + + it('returns false and cleans up textarea when document.execCommand throws', async () => { + mockExecCommand.mockImplementation(() => { + throw new Error('execCommand copy failed'); + }); + + const result = await copyToClipboard('fallback text'); + + expect(result).toBe(false); + expect(mockAppendChild).toHaveBeenCalledWith(createdTextarea); + expect(mockRemoveChild).toHaveBeenCalledWith(createdTextarea); + }); + }); + + describe('fallback when navigator.clipboard.writeText rejects', () => { + it('falls back to execCommand and returns false when execCommand returns false', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('Permission denied')); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + mockExecCommand.mockReturnValue(false); + + const result = await copyToClipboard('fallback text'); + + expect(result).toBe(false); + expect(writeText).toHaveBeenCalledWith('fallback text'); + expect(mockExecCommand).toHaveBeenCalledWith('copy'); + expect(mockAppendChild).toHaveBeenCalledWith(createdTextarea); + expect(mockRemoveChild).toHaveBeenCalledWith(createdTextarea); + }); + + it('falls back to execCommand and returns false when execCommand throws', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('Permission denied')); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + mockExecCommand.mockImplementation(() => { + throw new Error('execCommand threw'); + }); + + const result = await copyToClipboard('fallback text'); + + expect(result).toBe(false); + expect(writeText).toHaveBeenCalledWith('fallback text'); + expect(mockAppendChild).toHaveBeenCalledWith(createdTextarea); + expect(mockRemoveChild).toHaveBeenCalledWith(createdTextarea); + }); + + it('falls back to execCommand and returns true when execCommand succeeds', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('Permission denied')); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + mockExecCommand.mockReturnValue(true); + + const result = await copyToClipboard('fallback text'); + + expect(result).toBe(true); + expect(writeText).toHaveBeenCalledWith('fallback text'); + expect(mockExecCommand).toHaveBeenCalledWith('copy'); + expect(mockAppendChild).toHaveBeenCalledWith(createdTextarea); + expect(mockRemoveChild).toHaveBeenCalledWith(createdTextarea); + }); + }); + + it('returns false when neither clipboard nor document is available', async () => { + vi.stubGlobal('navigator', undefined); + vi.stubGlobal('document', undefined); + + const result = await copyToClipboard('test'); + expect(result).toBe(false); + }); +});