diff --git a/packages/hooks/src/useClipboard/__tests__/index.spec.ts b/packages/hooks/src/useClipboard/__tests__/index.spec.ts new file mode 100644 index 0000000000..e5c4a64c94 --- /dev/null +++ b/packages/hooks/src/useClipboard/__tests__/index.spec.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import useClipboard from '../index'; + +// 模拟 ClipboardItem +class MockClipboardItem { + constructor(public items: Record) {} + + get types() { + return Object.keys(this.items); + } + + async getType(type: string): Promise { + const blob = this.items[type]; + if (!blob) { + throw new Error(`Type ${type} not found`); + } + return blob; + } + + static supports = vi.fn().mockReturnValue(true); +} + +// 全局模拟 + +global.ClipboardItem = MockClipboardItem as any; + +global.Blob = class MockBlob { + constructor(public content: string[], public options: { type: string }) {} + async text(): Promise { + return this.content.join(''); + } +} as any; + +// 模拟 btoa 和 atob + +global.btoa = vi.fn((str: string) => Buffer.from(str, 'binary').toString('base64')); + +global.atob = vi.fn((str: string) => Buffer.from(str, 'base64').toString('binary')); + +describe('useClipboard', () => { + let mockClipboard: { + write: ReturnType; + read: ReturnType; + readText: ReturnType; + }; + + let mockPermissions: { + query: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockClipboard = { + write: vi.fn(), + read: vi.fn(), + readText: vi.fn(), + }; + + mockPermissions = { + query: vi.fn(), + }; + + Object.defineProperty(window, 'isSecureContext', { + value: true, + configurable: true, + }); + + Object.defineProperty(window.navigator, 'clipboard', { + value: mockClipboard, + configurable: true, + }); + + Object.defineProperty(window.navigator, 'permissions', { + value: mockPermissions, + configurable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('isSupported', () => { + it('should return true when clipboard API is available in secure context', () => { + const { result } = renderHook(() => useClipboard()); + expect(result.current.isSupported).toBe(true); + }); + + it('should return false when not in secure context', () => { + Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true }); + const { result } = renderHook(() => useClipboard()); + expect(result.current.isSupported).toBe(false); + }); + + it('should return false when clipboard API is not available', () => { + // 删除属性而不是设为 undefined + // @ts-expect-error + delete window.navigator.clipboard; + const { result } = renderHook(() => useClipboard()); + expect(result.current.isSupported).toBe(false); + }); + }); + + describe('hasPermission', () => { + it('should return true when both read and write permissions are granted', async () => { + mockPermissions.query.mockResolvedValueOnce({ state: 'granted' }); + mockPermissions.query.mockResolvedValueOnce({ state: 'granted' }); + + const { result } = renderHook(() => useClipboard()); + await act(async () => { + const hasPermission = await result.current.hasPermission(); + expect(hasPermission).toBe(true); + }); + }); + + it('should return false when write permission is denied', async () => { + mockPermissions.query.mockResolvedValueOnce({ state: 'denied' }); + mockPermissions.query.mockResolvedValueOnce({ state: 'granted' }); + + const { result } = renderHook(() => useClipboard()); + await act(async () => { + const hasPermission = await result.current.hasPermission(); + expect(hasPermission).toBe(false); + }); + }); + + it('should fallback to readText test when permissions API fails', async () => { + mockPermissions.query.mockRejectedValue(new Error('Permissions API not available')); + mockClipboard.readText.mockResolvedValue('test'); + + const { result } = renderHook(() => useClipboard()); + await act(async () => { + const hasPermission = await result.current.hasPermission(); + expect(hasPermission).toBe(true); + }); + }); + + it('should return false when not supported', async () => { + // @ts-expect-error + delete window.navigator.clipboard; + // @ts-expect-error + delete window.navigator.permissions; + + const { result } = renderHook(() => useClipboard()); + await act(async () => { + const hasPermission = await result.current.hasPermission(); + expect(hasPermission).toBe(false); + }); + }); + }); + + describe('copy', () => { + it('should successfully copy simple data with HTML support', async () => { + mockClipboard.write.mockResolvedValue(undefined); + MockClipboardItem.supports.mockReturnValue(true); + + const { result } = renderHook(() => useClipboard()); + await act(async () => { + await result.current.copy('Test display text', { id: 1 }); + }); + expect(mockClipboard.write).toHaveBeenCalled(); + }); + + it('should throw error when not supported', async () => { + // @ts-expect-error + delete window.navigator.clipboard; + + const { result } = renderHook(() => useClipboard()); + await act(async () => { + await expect(result.current.copy('test', { data: 'test' })) + .rejects.toThrow('Clipboard API is not supported in this environment'); + }); + }); + }); + + describe('paste', () => { + it('should return null when clipboard is empty', async () => { + mockClipboard.read.mockResolvedValue([]); + + const { result } = renderHook(() => useClipboard()); + await act(async () => { + const pasted = await result.current.paste(); + expect(pasted).toBeNull(); + }); + }); + + it('should throw error when not supported', async () => { + // @ts-expect-error + delete window.navigator.clipboard; + + const { result } = renderHook(() => useClipboard()); + await act(async () => { + await expect(result.current.paste()) + .rejects.toThrow('Clipboard API is not supported in this environment'); + }); + }); + }); +}); diff --git a/packages/hooks/src/useClipboard/demo/demo1.tsx b/packages/hooks/src/useClipboard/demo/demo1.tsx new file mode 100644 index 0000000000..d8c6575f86 --- /dev/null +++ b/packages/hooks/src/useClipboard/demo/demo1.tsx @@ -0,0 +1,145 @@ +import React, { useState } from 'react'; +import { Button, Card, Input, message, Space, Typography } from 'antd'; +import { FileTextOutlined, UserOutlined } from '@ant-design/icons'; +import useClipboard from '../index'; + +const { TextArea } = Input; +const { Text } = Typography; + +interface UserInfo { + id: number; + name: string; + email: string; + age: number; + address: { + city: string; + district: string; + }; +} + +const Demo1: React.FC = () => { + const [pastedData, setPastedData] = useState(null); + const { copy, paste, isSupported } = useClipboard(); + + // 示例数据 + const userInfo: UserInfo = { + id: 1, + name: 'John Doe', + email: 'john@example.com', + age: 28, + address: { + city: 'Beijing', + district: 'Haidian District', + }, + }; + const configObject = { + theme: 'dark', + language: 'zh-CN', + features: { + clipboard: true, + notifications: false, + autoSave: true, + }, + lastModified: new Date().toISOString(), + }; + + const handleCopyData = async (data: any, displayText: string) => { + try { + await copy(displayText, data); + message.success(`${displayText} 复制成功!`); + } catch (error:any) { + message.error(`复制失败: ${error?.message}`); + } + }; + + const handlePaste = async (showDisplayText?: boolean) => { + try { + const data = showDisplayText ? await paste() : await navigator.clipboard.readText(); + setPastedData(data); + if (data) { + message.success('粘贴成功!'); + } else { + message.info('剪贴板为空'); + } + } catch (error:any) { + message.error(`粘贴失败: ${error?.message}`); + } + }; + + if (!isSupported) { + return ( +
+ Clipboard API is not supported in the current environment +
+ ); + } + + return ( +
+ + {/* 用户信息 */} + + displayText:UserInfo + + } + > +
{JSON.stringify(userInfo, null, 2)}
+ +
+ + {/* 配置对象 */} + + displayText:ConfigObject + + } + > +
{JSON.stringify(configObject, null, 2)}
+ +
+ + + + {/* 显示粘贴的数据 */} + {pastedData && ( + +
+ Data Type: + {typeof pastedData} +
+