Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions packages/hooks/src/useClipboard/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, Blob>) {}

get types() {
return Object.keys(this.items);
}

async getType(type: string): Promise<Blob> {
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<string> {
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<typeof vi.fn>;
read: ReturnType<typeof vi.fn>;
readText: ReturnType<typeof vi.fn>;
};

let mockPermissions: {
query: ReturnType<typeof vi.fn>;
};

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');
});
});
});
});
145 changes: 145 additions & 0 deletions packages/hooks/src/useClipboard/demo/demo1.tsx
Original file line number Diff line number Diff line change
@@ -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<any>(null);
const { copy, paste, isSupported } = useClipboard();

// 示例数据
const userInfo: UserInfo = {
id: 1,
name: 'John Doe',
email: '[email protected]',
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 (
<div style={{ padding: '20px', color: '#ff4d4f' }}>
Clipboard API is not supported in the current environment
</div>
);
}

return (
<div style={{ padding: '20px' }}>
<Space direction='vertical' size='large' style={{ width: '100%' }}>
{/* 用户信息 */}
<Card
size='small'
title={
<>
<UserOutlined /> displayText:UserInfo
</>
}
>
<pre style={{ fontSize: '12px', margin: 0 }}>{JSON.stringify(userInfo, null, 2)}</pre>
<Button
size='small'
style={{ marginTop: '8px' }}
onClick={() => handleCopyData(userInfo, 'UserInfo')}
>
Copy
</Button>
</Card>

{/* 配置对象 */}
<Card
size='small'
title={
<>
<FileTextOutlined /> displayText:ConfigObject
</>
}
>
<pre style={{ fontSize: '12px', margin: 0 }}>{JSON.stringify(configObject, null, 2)}</pre>
<Button
size='small'
style={{ marginTop: '8px' }}
onClick={() => handleCopyData(configObject, 'ConfigObject')}
>
Copy
</Button>
</Card>

<Button type='primary' onClick={() => handlePaste()}>
Paste(DisplayText)
</Button>
<Button type='primary' onClick={() => handlePaste(true)}>
Paste(Data)
</Button>
{/* 显示粘贴的数据 */}
{pastedData && (
<Card title='Paste content' size='small'>
<div style={{ marginBottom: '8px' }}>
<Text strong>Data Type: </Text>
<Text code>{typeof pastedData}</Text>
</div>
<TextArea
value={
typeof pastedData === 'string' ? pastedData : JSON.stringify(pastedData, null, 2)
}
rows={8}
readOnly
/>
</Card>
)}
</Space>
</div>
);
};

export default Demo1;
Loading