refactor: reorganize directory structure into src/
Move all source code directories into src/ for cleaner project structure: - pages/, components/, utils/, config/, providers/, types/, lib/, assets/, entrypoints/ → src/ - Use WXT srcDir config to resolve @/ alias to src/ - Update tsconfig, vitest, eslint, tailwind configs - Remove scattered README.md files from subdirectories - Update documentation (AGENTS.md, CODING_STANDARDS.md, README.md) - Fix pre-existing lint error in RouterProvider.tsx Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
textToBase64,
|
||||
base64ToText,
|
||||
isValidBase64,
|
||||
isFileSizeValid,
|
||||
isSupportedImageType,
|
||||
isSupportedImageExtension,
|
||||
extractMimeTypeFromDataUri,
|
||||
formatFileSize,
|
||||
base64ToBytes,
|
||||
sniffMimeFromBytes,
|
||||
base64ToBlob,
|
||||
MAX_FILE_SIZE,
|
||||
} from '@/utils/base64Converter';
|
||||
|
||||
describe('textToBase64', () => {
|
||||
it('应该编码 ASCII 文本', () => {
|
||||
const result = textToBase64('Hello, World!');
|
||||
expect(result.output).toBe('SGVsbG8sIFdvcmxkIQ==');
|
||||
expect(result.originalBytes).toBe(13);
|
||||
});
|
||||
|
||||
it('应该编码中文文本', () => {
|
||||
const result = textToBase64('你好世界');
|
||||
expect(result.output).toBeTruthy();
|
||||
// 验证可以正确解码回来
|
||||
const decoded = base64ToText(result.output);
|
||||
expect(decoded).toBe('你好世界');
|
||||
});
|
||||
|
||||
it('应该编码空字符串', () => {
|
||||
const result = textToBase64('');
|
||||
expect(result.output).toBe('');
|
||||
expect(result.originalBytes).toBe(0);
|
||||
});
|
||||
|
||||
it('应该编码包含特殊字符的文本', () => {
|
||||
const text = 'line1\nline2\ttab';
|
||||
const result = textToBase64(text);
|
||||
const decoded = base64ToText(result.output);
|
||||
expect(decoded).toBe(text);
|
||||
});
|
||||
|
||||
it('应该编码 Unicode 表情符号', () => {
|
||||
const text = '🎉🚀';
|
||||
const result = textToBase64(text);
|
||||
const decoded = base64ToText(result.output);
|
||||
expect(decoded).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
describe('base64ToText', () => {
|
||||
it('应该解码标准 Base64 字符串', () => {
|
||||
const result = base64ToText('SGVsbG8sIFdvcmxkIQ==');
|
||||
expect(result).toBe('Hello, World!');
|
||||
});
|
||||
|
||||
it('应该解码中文 Base64', () => {
|
||||
const encoded = textToBase64('测试文本');
|
||||
const decoded = base64ToText(encoded.output);
|
||||
expect(decoded).toBe('测试文本');
|
||||
});
|
||||
|
||||
it('应该对无效 Base64 抛出错误', () => {
|
||||
expect(() => base64ToText('这不是base64!!!')).toThrow();
|
||||
});
|
||||
|
||||
it('应该修剪输入字符串的空白', () => {
|
||||
const result = base64ToText(' SGVsbG8= ');
|
||||
expect(result).toBe('Hello');
|
||||
});
|
||||
|
||||
it('应该自动剥离 data:<mime>;base64, 前缀后再解码', () => {
|
||||
// "hello" -> base64 "aGVsbG8="
|
||||
const result = base64ToText('data:text/plain;base64,aGVsbG8=');
|
||||
expect(result).toBe('hello');
|
||||
});
|
||||
|
||||
it('应该处理带参数(如 charset)的 data URI 前缀', () => {
|
||||
const result = base64ToText('data:text/plain;charset=utf-8;base64,aGVsbG8=');
|
||||
expect(result).toBe('hello');
|
||||
});
|
||||
|
||||
it('应该剥离带空白的 data URI 前缀', () => {
|
||||
const result = base64ToText(' data:text/plain;base64,aGVsbG8= ');
|
||||
expect(result).toBe('hello');
|
||||
});
|
||||
|
||||
it('应该对二进制(如 PNG)数据抛出更清晰的错误', () => {
|
||||
// PNG 文件签名 89 50 4E 47 0D 0A 1A 0A 的 Base64 编码
|
||||
const pngSignatureBase64 = 'iVBORw0KGgo=';
|
||||
expect(() => base64ToText(pngSignatureBase64)).toThrow(/binary|二进制|image|图像/i);
|
||||
});
|
||||
|
||||
it('应该对带 data:image/png 前缀的 PNG 数据抛出二进制错误', () => {
|
||||
const pngDataUri = 'data:image/png;base64,iVBORw0KGgo=';
|
||||
expect(() => base64ToText(pngDataUri)).toThrow(/binary|二进制|image|图像/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidBase64', () => {
|
||||
it('应该返回 true 对于合法的 Base64', () => {
|
||||
expect(isValidBase64('SGVsbG8=')).toBe(true);
|
||||
expect(isValidBase64('SGVsbG8sIFdvcmxkIQ==')).toBe(true);
|
||||
});
|
||||
|
||||
it('应该返回 false 对于空字符串', () => {
|
||||
expect(isValidBase64('')).toBe(false);
|
||||
});
|
||||
|
||||
it('应该返回 false 对于非法字符', () => {
|
||||
expect(isValidBase64('SGVsbG8!')).toBe(false);
|
||||
});
|
||||
|
||||
it('应该返回 false 对于长度不是 4 的倍数', () => {
|
||||
expect(isValidBase64('SGVsbG8')).toBe(false); // 7 chars, not divisible by 4
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFileSizeValid', () => {
|
||||
it('应该对正常大小的文件返回 true', () => {
|
||||
expect(isFileSizeValid(1024)).toBe(true);
|
||||
expect(isFileSizeValid(MAX_FILE_SIZE)).toBe(true);
|
||||
});
|
||||
|
||||
it('应该对 0 字节返回 false', () => {
|
||||
expect(isFileSizeValid(0)).toBe(false);
|
||||
});
|
||||
|
||||
it('应该对超出限制的文件返回 false', () => {
|
||||
expect(isFileSizeValid(MAX_FILE_SIZE + 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('应该对负数返回 false', () => {
|
||||
expect(isFileSizeValid(-1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSupportedImageType', () => {
|
||||
it('应该识别支持的图像类型', () => {
|
||||
expect(isSupportedImageType('image/png')).toBe(true);
|
||||
expect(isSupportedImageType('image/jpeg')).toBe(true);
|
||||
expect(isSupportedImageType('image/webp')).toBe(true);
|
||||
expect(isSupportedImageType('image/gif')).toBe(true);
|
||||
expect(isSupportedImageType('image/svg+xml')).toBe(true);
|
||||
});
|
||||
|
||||
it('应该拒绝不支持的 MIME 类型', () => {
|
||||
expect(isSupportedImageType('application/pdf')).toBe(false);
|
||||
expect(isSupportedImageType('text/plain')).toBe(false);
|
||||
expect(isSupportedImageType('video/mp4')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSupportedImageExtension', () => {
|
||||
it('应该识别支持的图像扩展名', () => {
|
||||
expect(isSupportedImageExtension('photo.png')).toBe(true);
|
||||
expect(isSupportedImageExtension('photo.JPG')).toBe(true);
|
||||
expect(isSupportedImageExtension('photo.webp')).toBe(true);
|
||||
expect(isSupportedImageExtension('photo.gif')).toBe(true);
|
||||
});
|
||||
|
||||
it('应该拒绝不支持的扩展名', () => {
|
||||
expect(isSupportedImageExtension('document.pdf')).toBe(false);
|
||||
expect(isSupportedImageExtension('video.mp4')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractMimeTypeFromDataUri', () => {
|
||||
it('应该从 data URI 中提取 MIME 类型', () => {
|
||||
expect(extractMimeTypeFromDataUri('data:image/png;base64,iVBOR')).toBe('image/png');
|
||||
expect(extractMimeTypeFromDataUri('data:text/plain;base64,SGVsbG8=')).toBe('text/plain');
|
||||
});
|
||||
|
||||
it('应该从带参数的 data URI 中提取 MIME 类型', () => {
|
||||
expect(extractMimeTypeFromDataUri('data:text/plain;charset=utf-8;base64,SGVsbG8=')).toBe(
|
||||
'text/plain',
|
||||
);
|
||||
expect(extractMimeTypeFromDataUri('data:image/svg+xml;base64,PHN2Zw==')).toBe('image/svg+xml');
|
||||
});
|
||||
|
||||
it('应该对无效的 data URI 返回默认类型', () => {
|
||||
expect(extractMimeTypeFromDataUri('invalid')).toBe('application/octet-stream');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFileSize', () => {
|
||||
it('应该格式化字节', () => {
|
||||
expect(formatFileSize(0)).toBe('0 B');
|
||||
expect(formatFileSize(512)).toBe('512 B');
|
||||
});
|
||||
|
||||
it('应该格式化 KB', () => {
|
||||
expect(formatFileSize(1024)).toBe('1.0 KB');
|
||||
expect(formatFileSize(1536)).toBe('1.5 KB');
|
||||
});
|
||||
|
||||
it('应该格式化 MB', () => {
|
||||
expect(formatFileSize(1048576)).toBe('1.00 MB');
|
||||
});
|
||||
|
||||
it('应该格式化 GB', () => {
|
||||
expect(formatFileSize(1073741824)).toBe('1.00 GB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('base64ToBytes', () => {
|
||||
it('应该解码标准 ASCII Base64 为字节序列', () => {
|
||||
const bytes = base64ToBytes('aGVsbG8=');
|
||||
expect(Array.from(bytes)).toEqual([0x68, 0x65, 0x6c, 0x6c, 0x6f]);
|
||||
});
|
||||
|
||||
it('应该正确解码 PNG 文件签名', () => {
|
||||
// PNG 签名:89 50 4E 47 0D 0A 1A 0A
|
||||
const bytes = base64ToBytes('iVBORw0KGgo=');
|
||||
expect(Array.from(bytes)).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
});
|
||||
|
||||
it('应该返回空 Uint8Array 对于空字符串输入', () => {
|
||||
const bytes = base64ToBytes('');
|
||||
expect(bytes.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sniffMimeFromBytes', () => {
|
||||
it('应该识别 PNG', () => {
|
||||
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
expect(sniffMimeFromBytes(bytes)).toEqual({ mime: 'image/png', ext: '.png' });
|
||||
});
|
||||
|
||||
it('应该识别 JPEG', () => {
|
||||
const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
|
||||
expect(sniffMimeFromBytes(bytes)).toEqual({ mime: 'image/jpeg', ext: '.jpg' });
|
||||
});
|
||||
|
||||
it('应该识别 GIF', () => {
|
||||
const bytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
|
||||
expect(sniffMimeFromBytes(bytes)).toEqual({ mime: 'image/gif', ext: '.gif' });
|
||||
});
|
||||
|
||||
it('应该识别 BMP', () => {
|
||||
const bytes = new Uint8Array([0x42, 0x4d, 0x36, 0x00, 0x00, 0x00]);
|
||||
expect(sniffMimeFromBytes(bytes)).toEqual({ mime: 'image/bmp', ext: '.bmp' });
|
||||
});
|
||||
|
||||
it('应该识别 WebP(RIFF....WEBP 复合签名)', () => {
|
||||
const bytes = new Uint8Array([
|
||||
0x52,
|
||||
0x49,
|
||||
0x46,
|
||||
0x46, // "RIFF"
|
||||
0x24,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // 文件大小占位
|
||||
0x57,
|
||||
0x45,
|
||||
0x42,
|
||||
0x50, // "WEBP"
|
||||
0x56,
|
||||
0x50,
|
||||
0x38,
|
||||
0x20, // "VP8 " 子块
|
||||
]);
|
||||
expect(sniffMimeFromBytes(bytes)).toEqual({ mime: 'image/webp', ext: '.webp' });
|
||||
});
|
||||
|
||||
it('不应将 WAV(RIFF 容器但非 WEBP)识别为 WebP', () => {
|
||||
const bytes = new Uint8Array([
|
||||
0x52,
|
||||
0x49,
|
||||
0x46,
|
||||
0x46, // "RIFF"
|
||||
0x24,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x57,
|
||||
0x41,
|
||||
0x56,
|
||||
0x45, // "WAVE"
|
||||
]);
|
||||
expect(sniffMimeFromBytes(bytes)).toBeNull();
|
||||
});
|
||||
it('应该识别 PDF', () => {
|
||||
const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]);
|
||||
expect(sniffMimeFromBytes(bytes)).toEqual({ mime: 'application/pdf', ext: '.pdf' });
|
||||
});
|
||||
|
||||
it('应该识别 ZIP', () => {
|
||||
const bytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
|
||||
expect(sniffMimeFromBytes(bytes)).toEqual({ mime: 'application/zip', ext: '.zip' });
|
||||
});
|
||||
|
||||
it('应该对未匹配的字节返回 null', () => {
|
||||
const bytes = new Uint8Array([0x00, 0x01, 0x02, 0x03]);
|
||||
expect(sniffMimeFromBytes(bytes)).toBeNull();
|
||||
});
|
||||
|
||||
it('应该对过短的字节返回 null', () => {
|
||||
const bytes = new Uint8Array([0x89]);
|
||||
expect(sniffMimeFromBytes(bytes)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('base64ToBlob', () => {
|
||||
it('应该优先使用 data URI 中的 MIME 类型', () => {
|
||||
const result = base64ToBlob('data:application/json;base64,eyJhIjoxfQ==');
|
||||
expect(result.mimeType).toBe('application/json');
|
||||
expect(result.blob).toBeInstanceOf(Blob);
|
||||
expect(result.blob.size).toBe(7);
|
||||
});
|
||||
|
||||
it('应该通过魔数识别 PNG', () => {
|
||||
const result = base64ToBlob('iVBORw0KGgo=');
|
||||
expect(result.mimeType).toBe('image/png');
|
||||
expect(result.suggestedExtension).toBe('.png');
|
||||
});
|
||||
|
||||
it('应该通过魔数识别 PDF', () => {
|
||||
// "%PDF-" + 一些字节
|
||||
const result = base64ToBlob('JVBERi0K');
|
||||
expect(result.mimeType).toBe('application/pdf');
|
||||
expect(result.suggestedExtension).toBe('.pdf');
|
||||
});
|
||||
|
||||
it('应该对未匹配的纯 Base64 回退为 application/octet-stream + .bin', () => {
|
||||
const result = base64ToBlob('AAECAwQF');
|
||||
expect(result.mimeType).toBe('application/octet-stream');
|
||||
expect(result.suggestedExtension).toBe('.bin');
|
||||
});
|
||||
|
||||
it('应该 trim 前后空白', () => {
|
||||
const result = base64ToBlob(' iVBORw0KGgo= ');
|
||||
expect(result.mimeType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('应该对非法 Base64 抛出 Invalid Base64 string', () => {
|
||||
expect(() => base64ToBlob('这不是 base64!')).toThrow('Invalid Base64 string');
|
||||
});
|
||||
|
||||
it('应该返回原始 Base64(已去除 data URI 前缀)', () => {
|
||||
const result = base64ToBlob('data:image/png;base64,iVBORw0KGgo=');
|
||||
expect(result.rawBase64).toBe('iVBORw0KGgo=');
|
||||
});
|
||||
|
||||
it('应该处理带参数的 data URI 前缀', () => {
|
||||
const result = base64ToBlob('data:text/plain;charset=utf-8;base64,aGVsbG8=');
|
||||
expect(result.rawBase64).toBe('aGVsbG8=');
|
||||
expect(result.mimeType).toBe('text/plain');
|
||||
});
|
||||
|
||||
it('blob 大小应该等于解码后的字节数', () => {
|
||||
const result = base64ToBlob('aGVsbG8=');
|
||||
expect(result.blob.size).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
|
||||
describe('chromeStorage', () => {
|
||||
describe('get', () => {
|
||||
it('应该返回存储的值', async () => {
|
||||
(chrome.storage.local.get as any).mockResolvedValue({ 'app/theme': 'dark' });
|
||||
|
||||
const result = await storageUtil.get('app/theme');
|
||||
|
||||
expect(result).toBe('dark');
|
||||
expect(chrome.storage.local.get).toHaveBeenCalledWith(['app/theme']);
|
||||
});
|
||||
|
||||
it('当键不存在时应返回默认值', async () => {
|
||||
(chrome.storage.local.get as any).mockResolvedValue({});
|
||||
|
||||
const result = await storageUtil.get('app/theme', 'light');
|
||||
|
||||
expect(result).toBe('light');
|
||||
});
|
||||
|
||||
it('当键不存在且未提供默认值时应返回 undefined', async () => {
|
||||
(chrome.storage.local.get as any).mockResolvedValue({});
|
||||
|
||||
const result = await storageUtil.get('app/theme');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该支持布尔类型值', async () => {
|
||||
(chrome.storage.local.get as any).mockResolvedValue({ 'qrCode/qrExpanded': true });
|
||||
|
||||
const result = await storageUtil.get('qrCode/qrExpanded');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('应该支持数组类型值', async () => {
|
||||
const pages = ['dashboard', 'timestamp'] as const;
|
||||
(chrome.storage.local.get as any).mockResolvedValue({ 'app/visiblePages': pages });
|
||||
|
||||
const result = await storageUtil.get('app/visiblePages');
|
||||
|
||||
expect(result).toEqual(pages);
|
||||
});
|
||||
|
||||
it('应该支持复杂对象类型值', async () => {
|
||||
const preferences = {
|
||||
autoRefresh: true,
|
||||
selectedTypes: {
|
||||
localStorage: true,
|
||||
sessionStorage: false,
|
||||
indexedDB: true,
|
||||
cookies: false,
|
||||
cacheStorage: false,
|
||||
serviceWorkers: false,
|
||||
},
|
||||
};
|
||||
(chrome.storage.local.get as any).mockResolvedValue({
|
||||
'storageCleaner/preferences': preferences,
|
||||
});
|
||||
|
||||
const result = await storageUtil.get('storageCleaner/preferences');
|
||||
|
||||
expect(result).toEqual(preferences);
|
||||
});
|
||||
|
||||
it('当存储值为 null 时应返回默认值', async () => {
|
||||
(chrome.storage.local.get as any).mockResolvedValue({ 'app/theme': null });
|
||||
|
||||
const result = await storageUtil.get('app/theme', 'light');
|
||||
|
||||
expect(result).toBe('light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('set', () => {
|
||||
it('应该成功设置字符串值', async () => {
|
||||
(chrome.storage.local.set as any).mockResolvedValue(undefined);
|
||||
|
||||
await storageUtil.set('app/theme', 'dark');
|
||||
|
||||
expect(chrome.storage.local.set).toHaveBeenCalledWith({ 'app/theme': 'dark' });
|
||||
});
|
||||
|
||||
it('应该成功设置布尔值', async () => {
|
||||
await storageUtil.set('qrCode/qrExpanded', true);
|
||||
|
||||
expect(chrome.storage.local.set).toHaveBeenCalledWith({ 'qrCode/qrExpanded': true });
|
||||
});
|
||||
|
||||
it('应该成功设置数组值', async () => {
|
||||
const pages: Array<'dashboard' | 'timestamp'> = ['dashboard', 'timestamp'];
|
||||
await storageUtil.set('app/visiblePages', pages);
|
||||
|
||||
expect(chrome.storage.local.set).toHaveBeenCalledWith({ 'app/visiblePages': pages });
|
||||
});
|
||||
|
||||
it('应该成功设置复杂对象值', async () => {
|
||||
const preferences = {
|
||||
reloadAfterClean: false,
|
||||
selectedTypes: {
|
||||
localStorage: true,
|
||||
sessionStorage: true,
|
||||
indexedDB: false,
|
||||
cookies: false,
|
||||
cacheStorage: false,
|
||||
serviceWorkers: false,
|
||||
},
|
||||
};
|
||||
await storageUtil.set('storageCleaner/preferences', preferences);
|
||||
|
||||
expect(chrome.storage.local.set).toHaveBeenCalledWith({
|
||||
'storageCleaner/preferences': preferences,
|
||||
});
|
||||
});
|
||||
|
||||
it('应该成功设置枚举类型值', async () => {
|
||||
await storageUtil.set('jsonTools/pageMode', 'yaml');
|
||||
|
||||
expect(chrome.storage.local.set).toHaveBeenCalledWith({ 'jsonTools/pageMode': 'yaml' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('应该成功删除指定键', async () => {
|
||||
await storageUtil.remove('app/theme');
|
||||
|
||||
expect(chrome.storage.local.remove).toHaveBeenCalledWith(['app/theme']);
|
||||
});
|
||||
|
||||
it('应该成功删除不同键', async () => {
|
||||
await storageUtil.remove('qrCode/qrExpanded');
|
||||
|
||||
expect(chrome.storage.local.remove).toHaveBeenCalledWith(['qrCode/qrExpanded']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
getActiveTab,
|
||||
getActiveTabDomain,
|
||||
openExtensionPage,
|
||||
ensureContentScriptInjected,
|
||||
} from '@/utils/chromeTabs';
|
||||
|
||||
describe('chromeTabs', () => {
|
||||
describe('getActiveTab', () => {
|
||||
it('应该返回当前活动标签页', async () => {
|
||||
const mockTab = { id: 1, url: 'https://example.com', title: 'Example' } as chrome.tabs.Tab;
|
||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||
|
||||
const result = await getActiveTab();
|
||||
|
||||
expect(result).toEqual(mockTab);
|
||||
expect(chrome.tabs.query).toHaveBeenCalledWith({ active: true, currentWindow: true });
|
||||
});
|
||||
|
||||
it('当没有活动标签页时应返回 null', async () => {
|
||||
(chrome.tabs.query as any).mockResolvedValue([]);
|
||||
|
||||
const result = await getActiveTab();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('当查询失败时应返回 null 并记录错误', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
(chrome.tabs.query as any).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const result = await getActiveTab();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalledWith('获取活动标签页失败:', expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveTabDomain', () => {
|
||||
it('应该返回当前活动标签页的域名', async () => {
|
||||
const mockTab = { id: 1, url: 'https://example.com/path?query=1' } as chrome.tabs.Tab;
|
||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||
|
||||
const result = await getActiveTabDomain();
|
||||
|
||||
expect(result).toBe('example.com');
|
||||
});
|
||||
|
||||
it('应该处理带有端口的 URL', async () => {
|
||||
const mockTab = { id: 1, url: 'https://example.com:8080/path' } as chrome.tabs.Tab;
|
||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||
|
||||
const result = await getActiveTabDomain();
|
||||
|
||||
expect(result).toBe('example.com');
|
||||
});
|
||||
|
||||
it('当标签页没有 URL 时应返回空字符串', async () => {
|
||||
const mockTab = { id: 1 } as chrome.tabs.Tab;
|
||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||
|
||||
const result = await getActiveTabDomain();
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('当没有活动标签页时应返回空字符串', async () => {
|
||||
(chrome.tabs.query as any).mockResolvedValue([]);
|
||||
|
||||
const result = await getActiveTabDomain();
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('当 URL 解析失败时应返回空字符串并记录错误', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const mockTab = { id: 1, url: 'not-a-valid-url' } as chrome.tabs.Tab;
|
||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||
|
||||
const result = await getActiveTabDomain();
|
||||
|
||||
expect(result).toBe('');
|
||||
expect(consoleSpy).toHaveBeenCalledWith('解析域名失败:', expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('应该处理 chrome-extension URL', async () => {
|
||||
const mockTab = { id: 1, url: 'chrome-extension://abc123/popup.html' } as chrome.tabs.Tab;
|
||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||
|
||||
const result = await getActiveTabDomain();
|
||||
|
||||
expect(result).toBe('abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('openExtensionPage', () => {
|
||||
it('应该在新标签页中打开扩展页面', async () => {
|
||||
await openExtensionPage('popup.html');
|
||||
|
||||
expect(chrome.runtime.getURL).toHaveBeenCalledWith('popup.html');
|
||||
expect(chrome.tabs.create).toHaveBeenCalledWith({
|
||||
url: 'chrome-extension://test-extension-id/popup.html',
|
||||
});
|
||||
});
|
||||
|
||||
it('应该支持带查询参数的扩展页面', async () => {
|
||||
await openExtensionPage('options.html', { tab: 'settings', id: '123' });
|
||||
|
||||
expect(chrome.tabs.create).toHaveBeenCalledWith({
|
||||
url: 'chrome-extension://test-extension-id/options.html?tab=settings&id=123',
|
||||
});
|
||||
});
|
||||
|
||||
it('当创建标签页失败时应记录错误', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
(chrome.tabs.create as any).mockRejectedValue(new Error('Tab creation failed'));
|
||||
|
||||
await openExtensionPage('popup.html');
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('打开扩展页面失败:', expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureContentScriptInjected', () => {
|
||||
it('当存在活动标签页时应返回 true', async () => {
|
||||
const mockTab = { id: 123, url: 'https://example.com' } as chrome.tabs.Tab;
|
||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||
|
||||
const result = await ensureContentScriptInjected();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('当没有活动标签页时应返回 false', async () => {
|
||||
(chrome.tabs.query as any).mockResolvedValue([]);
|
||||
|
||||
const result = await ensureContentScriptInjected();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('当标签页没有 id 时应返回 false', async () => {
|
||||
const mockTab = { url: 'https://example.com' } as chrome.tabs.Tab;
|
||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||
|
||||
const result = await ensureContentScriptInjected();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('当整体操作失败时应返回 false 并记录错误', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
(chrome.tabs.query as any).mockRejectedValue(new Error('Query failed'));
|
||||
|
||||
const result = await ensureContentScriptInjected();
|
||||
|
||||
expect(result).toBe(false);
|
||||
// getActiveTab catches the error and logs "获取活动标签页失败"
|
||||
expect(consoleSpy).toHaveBeenCalledWith('获取活动标签页失败:', expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi, beforeAll } from 'vitest';
|
||||
import { copyTextToClipboard, copyImageToClipboard } from '@/utils/clipboard';
|
||||
|
||||
// Mock ClipboardItem for test environment
|
||||
class MockClipboardItem {
|
||||
constructor(public items: Record<string, Blob>) {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
(globalThis as any).ClipboardItem = MockClipboardItem;
|
||||
});
|
||||
|
||||
describe('clipboard', () => {
|
||||
describe('copyTextToClipboard', () => {
|
||||
it('复制成功时应返回 true', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, { clipboard: { writeText } });
|
||||
|
||||
const result = await copyTextToClipboard('test text');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith('test text');
|
||||
});
|
||||
|
||||
it('复制失败时应返回 false', async () => {
|
||||
const writeText = vi.fn().mockRejectedValue(new Error('Permission denied'));
|
||||
Object.assign(navigator, { clipboard: { writeText } });
|
||||
|
||||
const result = await copyTextToClipboard('test text');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyImageToClipboard', () => {
|
||||
it('复制成功时应返回 true', async () => {
|
||||
const write = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, { clipboard: { write } });
|
||||
|
||||
const blob = new Blob(['png data'], { type: 'image/png' });
|
||||
const result = await copyImageToClipboard(blob);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(write).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('复制失败时应返回 false', async () => {
|
||||
const write = vi.fn().mockRejectedValue(new Error('Permission denied'));
|
||||
Object.assign(navigator, { clipboard: { write } });
|
||||
|
||||
const blob = new Blob(['png data'], { type: 'image/png' });
|
||||
const result = await copyImageToClipboard(blob);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
CONTEXT_MENU_CONFIGS,
|
||||
createAllContextMenus,
|
||||
parseContextMenuClick,
|
||||
MAX_PAYLOAD_LENGTH,
|
||||
} from '@/utils/contextMenu';
|
||||
|
||||
describe('contextMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('CONTEXT_MENU_CONFIGS', () => {
|
||||
it('应该包含 7 个菜单项配置', () => {
|
||||
expect(CONTEXT_MENU_CONFIGS).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('应该有一个父级菜单项 Testing Tools', () => {
|
||||
const parentMenu = CONTEXT_MENU_CONFIGS.find((c) => c.id === 'testing-tools-parent');
|
||||
expect(parentMenu).toBeDefined();
|
||||
expect(parentMenu?.title).toBe('Testing Tools');
|
||||
expect(parentMenu?.parentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该有 4 个 selection 上下文的子菜单', () => {
|
||||
const selectionMenus = CONTEXT_MENU_CONFIGS.filter(
|
||||
(c) => c.contexts[0] === 'selection' && c.parentId === 'testing-tools-parent',
|
||||
);
|
||||
expect(selectionMenus).toHaveLength(4);
|
||||
expect(selectionMenus.map((m) => m.id)).toEqual([
|
||||
'jwt',
|
||||
'base64Converter',
|
||||
'textStatistics',
|
||||
'timestamp',
|
||||
]);
|
||||
});
|
||||
|
||||
it('应该有 2 个 page 上下文的子菜单', () => {
|
||||
const pageMenus = CONTEXT_MENU_CONFIGS.filter(
|
||||
(c) => c.contexts[0] === 'page' && c.parentId === 'testing-tools-parent',
|
||||
);
|
||||
expect(pageMenus).toHaveLength(2);
|
||||
expect(pageMenus.map((m) => m.id)).toEqual(['storageCleaner', 'qrCode-page']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAllContextMenus', () => {
|
||||
it('应该为每个配置调用 chrome.contextMenus.create', () => {
|
||||
createAllContextMenus();
|
||||
|
||||
expect(chrome.contextMenus.create).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
|
||||
it('应该使用正确的参数创建菜单项', () => {
|
||||
createAllContextMenus();
|
||||
|
||||
expect(chrome.contextMenus.create).toHaveBeenCalledWith({
|
||||
id: 'testing-tools-parent',
|
||||
title: 'Testing Tools',
|
||||
contexts: ['all'],
|
||||
parentId: undefined,
|
||||
});
|
||||
|
||||
expect(chrome.contextMenus.create).toHaveBeenCalledWith({
|
||||
id: 'jwt',
|
||||
title: '🔑 解析 JWT',
|
||||
contexts: ['selection'],
|
||||
parentId: 'testing-tools-parent',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseContextMenuClick', () => {
|
||||
const createMockOnClickData = (
|
||||
overrides: Partial<chrome.contextMenus.OnClickData> = {},
|
||||
): chrome.contextMenus.OnClickData => ({
|
||||
menuItemId: 'test',
|
||||
editable: false,
|
||||
pageUrl: 'https://example.com',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('当点击 qrCode-page 菜单时应返回 qrCode 功能和 pageUrl', () => {
|
||||
const info = createMockOnClickData({
|
||||
pageUrl: 'https://example.com/page',
|
||||
});
|
||||
|
||||
const result = parseContextMenuClick('qrCode-page', info);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { featureKey: 'qrCode', payload: 'https://example.com/page' },
|
||||
});
|
||||
});
|
||||
|
||||
it('当点击有 selectionText 的菜单时应返回对应功能和选中文本', () => {
|
||||
const info = createMockOnClickData({
|
||||
selectionText: 'selected text',
|
||||
});
|
||||
|
||||
const result = parseContextMenuClick('jwt', info);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { featureKey: 'jwt', payload: 'selected text' },
|
||||
});
|
||||
});
|
||||
|
||||
it('当点击 timestamp 菜单时应正确映射功能键', () => {
|
||||
const info = createMockOnClickData({
|
||||
selectionText: '1234567890',
|
||||
});
|
||||
|
||||
const result = parseContextMenuClick('timestamp', info);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { featureKey: 'timestamp', payload: '1234567890' },
|
||||
});
|
||||
});
|
||||
|
||||
it('当点击 storageCleaner 菜单时应返回 pageUrl', () => {
|
||||
const info = createMockOnClickData({
|
||||
pageUrl: 'https://example.com',
|
||||
});
|
||||
|
||||
const result = parseContextMenuClick('storageCleaner', info);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { featureKey: 'storageCleaner', payload: 'https://example.com' },
|
||||
});
|
||||
});
|
||||
|
||||
it('当没有 selectionText 和 pageUrl 时应返回错误', () => {
|
||||
const info = createMockOnClickData({
|
||||
pageUrl: undefined,
|
||||
});
|
||||
|
||||
const result = parseContextMenuClick('someMenu', info);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: '无法获取有效数据',
|
||||
});
|
||||
});
|
||||
|
||||
it('selectionText 优先于 pageUrl', () => {
|
||||
const info = createMockOnClickData({
|
||||
selectionText: 'selected text',
|
||||
pageUrl: 'https://example.com',
|
||||
});
|
||||
|
||||
const result = parseContextMenuClick('jwt', info);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { featureKey: 'jwt', payload: 'selected text' },
|
||||
});
|
||||
});
|
||||
|
||||
it('当文本超过最大长度限制时应截断', () => {
|
||||
const longText = 'a'.repeat(MAX_PAYLOAD_LENGTH + 1000);
|
||||
const info = createMockOnClickData({
|
||||
selectionText: longText,
|
||||
});
|
||||
|
||||
const result = parseContextMenuClick('textStatistics', info);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.payload.length).toBe(MAX_PAYLOAD_LENGTH);
|
||||
});
|
||||
|
||||
it('当文本未超过最大长度限制时应保持原样', () => {
|
||||
const shortText = 'short text';
|
||||
const info = createMockOnClickData({
|
||||
selectionText: shortText,
|
||||
});
|
||||
|
||||
const result = parseContextMenuClick('textStatistics', info);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { featureKey: 'textStatistics', payload: 'short text' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import dayjs from '@/utils/dayjs';
|
||||
|
||||
describe('dayjs', () => {
|
||||
it('应该正确导出 dayjs 实例', () => {
|
||||
expect(dayjs).toBeDefined();
|
||||
expect(typeof dayjs).toBe('function');
|
||||
});
|
||||
|
||||
it('应该支持基本日期解析', () => {
|
||||
const date = dayjs('2024-01-15');
|
||||
expect(date.isValid()).toBe(true);
|
||||
expect(date.year()).toBe(2024);
|
||||
expect(date.month()).toBe(0);
|
||||
expect(date.date()).toBe(15);
|
||||
});
|
||||
|
||||
it('应该支持 UTC 插件', () => {
|
||||
const utcDate = dayjs.utc('2024-01-15T12:00:00Z');
|
||||
expect(utcDate.isValid()).toBe(true);
|
||||
expect(utcDate.format()).toContain('2024-01-15');
|
||||
});
|
||||
|
||||
it('应该支持时区插件', () => {
|
||||
const date = dayjs('2024-01-15T12:00:00');
|
||||
expect(date.tz).toBeDefined();
|
||||
expect(typeof date.tz).toBe('function');
|
||||
|
||||
const shanghaiDate = date.tz('Asia/Shanghai');
|
||||
expect(shanghaiDate.isValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('应该支持相对时间插件', () => {
|
||||
const now = dayjs();
|
||||
expect(now.fromNow).toBeDefined();
|
||||
expect(typeof now.fromNow).toBe('function');
|
||||
|
||||
const yesterday = dayjs().subtract(1, 'day');
|
||||
const fromNow = yesterday.fromNow();
|
||||
expect(typeof fromNow).toBe('string');
|
||||
expect(fromNow.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('应该使用中文 locale', () => {
|
||||
// 显式设置中文 locale
|
||||
dayjs.locale('zh-cn');
|
||||
const yesterday = dayjs().subtract(1, 'day');
|
||||
const fromNow = yesterday.fromNow();
|
||||
|
||||
// 中文相对时间应包含 "天前"
|
||||
expect(fromNow).toContain('天前');
|
||||
});
|
||||
|
||||
it('应该支持日期格式化', () => {
|
||||
const date = dayjs('2024-01-15T10:30:00');
|
||||
expect(date.format('YYYY-MM-DD')).toBe('2024-01-15');
|
||||
expect(date.format('YYYY年MM月DD日')).toBe('2024年01月15日');
|
||||
});
|
||||
|
||||
it('应该支持日期计算', () => {
|
||||
const date = dayjs('2024-01-15');
|
||||
const nextDay = date.add(1, 'day');
|
||||
expect(nextDay.date()).toBe(16);
|
||||
|
||||
const prevMonth = date.subtract(1, 'month');
|
||||
expect(prevMonth.month()).toBe(11);
|
||||
expect(prevMonth.year()).toBe(2023);
|
||||
});
|
||||
|
||||
it('应该支持日期比较', () => {
|
||||
const date1 = dayjs('2024-01-15');
|
||||
const date2 = dayjs('2024-01-20');
|
||||
|
||||
expect(date1.isBefore(date2)).toBe(true);
|
||||
expect(date2.isAfter(date1)).toBe(true);
|
||||
expect(date1.isSame(date2)).toBe(false);
|
||||
});
|
||||
|
||||
it('应该支持 Unix 时间戳转换', () => {
|
||||
const timestamp = 1705315200; // 2024-01-15 12:00:00 UTC
|
||||
const date = dayjs.unix(timestamp);
|
||||
|
||||
expect(date.isValid()).toBe(true);
|
||||
expect(date.year()).toBe(2024);
|
||||
});
|
||||
|
||||
it('应该支持毫秒时间戳', () => {
|
||||
const timestamp = 1705315200000;
|
||||
const date = dayjs(timestamp);
|
||||
|
||||
expect(date.isValid()).toBe(true);
|
||||
expect(date.year()).toBe(2024);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { diffJson } from '../../pages/JsonTools/diffEngine';
|
||||
|
||||
describe('diffJson', () => {
|
||||
it('should detect added property', () => {
|
||||
const left = { a: 1 };
|
||||
const right = { a: 1, b: 2 };
|
||||
const result = diffJson(left, right);
|
||||
expect(result.diffCount).toBe(1);
|
||||
expect(result.diffPaths).toContain('$.b');
|
||||
const bNode = result.root.children?.find((n) => n.key === 'b');
|
||||
expect(bNode?.type).toBe('added');
|
||||
});
|
||||
|
||||
it('should detect removed property', () => {
|
||||
const left = { a: 1, b: 2 };
|
||||
const right = { a: 1 };
|
||||
const result = diffJson(left, right);
|
||||
expect(result.diffCount).toBe(1);
|
||||
expect(result.diffPaths).toContain('$.b');
|
||||
});
|
||||
|
||||
it('should detect modified value', () => {
|
||||
const left = { a: 1 };
|
||||
const right = { a: 2 };
|
||||
const result = diffJson(left, right);
|
||||
expect(result.diffCount).toBe(1);
|
||||
expect(result.diffPaths).toContain('$.a');
|
||||
const aNode = result.root.children?.find((n) => n.key === 'a');
|
||||
expect(aNode?.type).toBe('modified');
|
||||
expect(aNode?.oldValue).toBe(1);
|
||||
expect(aNode?.newValue).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle nested objects', () => {
|
||||
const left = { a: { b: 1 } };
|
||||
const right = { a: { b: 2 } };
|
||||
const result = diffJson(left, right);
|
||||
expect(result.diffCount).toBe(1);
|
||||
expect(result.diffPaths).toContain('$.a.b');
|
||||
});
|
||||
|
||||
it('should handle arrays', () => {
|
||||
const left = [1, 2];
|
||||
const right = [1, 3];
|
||||
const result = diffJson(left, right);
|
||||
expect(result.diffCount).toBe(1);
|
||||
expect(result.diffPaths).toContain('$[1]');
|
||||
});
|
||||
|
||||
it('should return unchanged for identical objects', () => {
|
||||
const obj = { a: 1, b: { c: 2 } };
|
||||
const result = diffJson(obj, obj);
|
||||
expect(result.diffCount).toBe(0);
|
||||
expect(result.root.type).toBe('unchanged');
|
||||
});
|
||||
|
||||
it('should handle type mismatch', () => {
|
||||
const left = { a: 1 };
|
||||
const right = { a: '1' };
|
||||
const result = diffJson(left, right);
|
||||
expect(result.diffCount).toBe(1);
|
||||
const aNode = result.root.children?.find((n) => n.key === 'a');
|
||||
expect(aNode?.type).toBe('modified');
|
||||
});
|
||||
|
||||
it('should handle empty objects', () => {
|
||||
const result = diffJson({}, {});
|
||||
expect(result.diffCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('should format 0 bytes', () => {
|
||||
expect(formatBytes(0)).toBe('0 B');
|
||||
});
|
||||
|
||||
it('should format bytes less than 1024', () => {
|
||||
expect(formatBytes(1)).toBe('1 B');
|
||||
expect(formatBytes(100)).toBe('100 B');
|
||||
expect(formatBytes(500)).toBe('500 B');
|
||||
expect(formatBytes(512)).toBe('512 B');
|
||||
expect(formatBytes(1023)).toBe('1023 B');
|
||||
});
|
||||
|
||||
it('should format kilobytes', () => {
|
||||
expect(formatBytes(1024)).toBe('1.0 KB');
|
||||
expect(formatBytes(1025)).toBe('1.0 KB');
|
||||
expect(formatBytes(1536)).toBe('1.5 KB');
|
||||
expect(formatBytes(2048)).toBe('2.0 KB');
|
||||
});
|
||||
|
||||
it('should format megabytes', () => {
|
||||
expect(formatBytes(1048576)).toBe('1.00 MB');
|
||||
expect(formatBytes(1572864)).toBe('1.50 MB');
|
||||
expect(formatBytes(5242880)).toBe('5.00 MB');
|
||||
});
|
||||
|
||||
it('should format gigabytes', () => {
|
||||
expect(formatBytes(1073741824)).toBe('1.00 GB');
|
||||
expect(formatBytes(2147483648)).toBe('2.00 GB');
|
||||
});
|
||||
|
||||
it('should format terabytes', () => {
|
||||
expect(formatBytes(1099511627776)).toBe('1.00 TB');
|
||||
expect(formatBytes(2199023255552)).toBe('2.00 TB');
|
||||
});
|
||||
|
||||
it('should handle large values beyond TB', () => {
|
||||
// Should cap at TB
|
||||
expect(formatBytes(1125899906842624)).toBe('1024.00 TB');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { htmlToMarkdown, downloadMarkdownFile, SAMPLE_HTML } from '../htmlToMarkdown';
|
||||
|
||||
describe('htmlToMarkdown', () => {
|
||||
it('returns empty result for empty string', () => {
|
||||
const result = htmlToMarkdown('');
|
||||
expect(result.markdown).toBe('');
|
||||
expect(result.originalLength).toBe(0);
|
||||
expect(result.markdownLength).toBe(0);
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('returns empty result for whitespace-only string', () => {
|
||||
const result = htmlToMarkdown(' \n ');
|
||||
expect(result.markdown).toBe('');
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('converts h1 heading', () => {
|
||||
const result = htmlToMarkdown('<h1>Hello World</h1>');
|
||||
expect(result.markdown).toContain('# Hello World');
|
||||
});
|
||||
|
||||
it('converts h2-h6 headings', () => {
|
||||
const result = htmlToMarkdown(
|
||||
'<h2>Two</h2><h3>Three</h3><h4>Four</h4><h5>Five</h5><h6>Six</h6>',
|
||||
);
|
||||
expect(result.markdown).toContain('## Two');
|
||||
expect(result.markdown).toContain('### Three');
|
||||
expect(result.markdown).toContain('#### Four');
|
||||
expect(result.markdown).toContain('##### Five');
|
||||
expect(result.markdown).toContain('###### Six');
|
||||
});
|
||||
|
||||
it('converts paragraph', () => {
|
||||
const result = htmlToMarkdown('<p>This is a paragraph.</p>');
|
||||
expect(result.markdown).toContain('This is a paragraph.');
|
||||
});
|
||||
|
||||
it('converts strong and b tags', () => {
|
||||
const result = htmlToMarkdown('<strong>bold</strong> and <b>also bold</b>');
|
||||
expect(result.markdown).toContain('**bold**');
|
||||
expect(result.markdown).toContain('**also bold**');
|
||||
});
|
||||
|
||||
it('converts em and i tags', () => {
|
||||
const result = htmlToMarkdown('<em>italic</em> and <i>also italic</i>');
|
||||
expect(result.markdown).toContain('*italic*');
|
||||
expect(result.markdown).toContain('*also italic*');
|
||||
});
|
||||
|
||||
it('converts del and s tags', () => {
|
||||
const result = htmlToMarkdown('<del>deleted</del> and <s>strikethrough</s>');
|
||||
expect(result.markdown).toContain('~~deleted~~');
|
||||
expect(result.markdown).toContain('~~strikethrough~~');
|
||||
});
|
||||
|
||||
it('converts inline code', () => {
|
||||
const result = htmlToMarkdown('<code>const x = 1;</code>');
|
||||
expect(result.markdown).toContain('`const x = 1;`');
|
||||
});
|
||||
|
||||
it('converts pre code block', () => {
|
||||
const result = htmlToMarkdown('<pre><code>line1\nline2</code></pre>');
|
||||
expect(result.markdown).toContain('```');
|
||||
expect(result.markdown).toContain('line1');
|
||||
expect(result.markdown).toContain('line2');
|
||||
});
|
||||
|
||||
it('converts pre code block with language', () => {
|
||||
const result = htmlToMarkdown(
|
||||
'<pre><code class="language-javascript">const x = 1;</code></pre>',
|
||||
);
|
||||
expect(result.markdown).toContain('```javascript');
|
||||
expect(result.markdown).toContain('const x = 1;');
|
||||
});
|
||||
|
||||
it('converts anchor links', () => {
|
||||
const result = htmlToMarkdown('<a href="https://example.com">Link text</a>');
|
||||
expect(result.markdown).toContain('[Link text](https://example.com)');
|
||||
});
|
||||
|
||||
it('converts anchor links with title', () => {
|
||||
const result = htmlToMarkdown('<a href="https://example.com" title="Title">Link</a>');
|
||||
expect(result.markdown).toContain('[Link](https://example.com "Title")');
|
||||
});
|
||||
|
||||
it('converts images', () => {
|
||||
const result = htmlToMarkdown('<img src="image.png" alt="desc" />');
|
||||
expect(result.markdown).toContain('');
|
||||
});
|
||||
|
||||
it('converts images with title', () => {
|
||||
const result = htmlToMarkdown('<img src="image.png" alt="desc" title="Title" />');
|
||||
expect(result.markdown).toContain('');
|
||||
});
|
||||
|
||||
it('converts unordered list', () => {
|
||||
const result = htmlToMarkdown('<ul><li>Item 1</li><li>Item 2</li></ul>');
|
||||
expect(result.markdown).toContain('- Item 1');
|
||||
expect(result.markdown).toContain('- Item 2');
|
||||
});
|
||||
|
||||
it('converts ordered list', () => {
|
||||
const result = htmlToMarkdown('<ol><li>First</li><li>Second</li></ol>');
|
||||
expect(result.markdown).toContain('1. First');
|
||||
expect(result.markdown).toContain('2. Second');
|
||||
});
|
||||
|
||||
it('converts ordered list with start attribute', () => {
|
||||
const result = htmlToMarkdown('<ol start="5"><li>Item</li></ol>');
|
||||
expect(result.markdown).toContain('5. Item');
|
||||
});
|
||||
|
||||
it('converts blockquote', () => {
|
||||
const result = htmlToMarkdown('<blockquote><p>Quote text</p></blockquote>');
|
||||
expect(result.markdown).toContain('> Quote text');
|
||||
});
|
||||
|
||||
it('converts horizontal rule', () => {
|
||||
const result = htmlToMarkdown('<hr />');
|
||||
expect(result.markdown).toContain('---');
|
||||
});
|
||||
|
||||
it('converts line break', () => {
|
||||
const result = htmlToMarkdown('Line 1<br />Line 2');
|
||||
expect(result.markdown).toContain('\n');
|
||||
});
|
||||
|
||||
it('converts table', () => {
|
||||
const html =
|
||||
'<table><tr><th>Name</th><th>Type</th></tr><tr><td>John</td><td>User</td></tr></table>';
|
||||
const result = htmlToMarkdown(html);
|
||||
expect(result.markdown).toContain('| Name | Type |');
|
||||
expect(result.markdown).toContain('| --- | --- |');
|
||||
expect(result.markdown).toContain('| John | User |');
|
||||
});
|
||||
|
||||
it('ignores script and style tags', () => {
|
||||
const result = htmlToMarkdown('<script>alert(1)</script><style>.x{}</style><p>text</p>');
|
||||
expect(result.markdown).not.toContain('alert');
|
||||
expect(result.markdown).not.toContain('.x{}');
|
||||
expect(result.markdown).toContain('text');
|
||||
});
|
||||
|
||||
it('handles nested elements', () => {
|
||||
const result = htmlToMarkdown('<p><strong>bold</strong> and <em>italic</em></p>');
|
||||
expect(result.markdown).toContain('**bold**');
|
||||
expect(result.markdown).toContain('*italic*');
|
||||
});
|
||||
|
||||
it('returns hasError false for valid HTML', () => {
|
||||
const result = htmlToMarkdown('<p>Valid</p>');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns correct length stats', () => {
|
||||
const html = '<p>Hello</p>';
|
||||
const result = htmlToMarkdown(html);
|
||||
expect(result.originalLength).toBe(html.length);
|
||||
expect(result.markdownLength).toBe(result.markdown.length);
|
||||
});
|
||||
|
||||
it('handles full HTML document', () => {
|
||||
const result = htmlToMarkdown(SAMPLE_HTML);
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.markdown).toContain('# 欢迎使用 HTML 转 Markdown');
|
||||
expect(result.markdown).toContain('**HTML**');
|
||||
expect(result.markdown).toContain('*Markdown*');
|
||||
expect(result.markdown).toContain('```javascript');
|
||||
expect(result.markdown).toContain('| 名称 | 类型 |');
|
||||
expect(result.markdown).toContain('> 这是一段引用文本');
|
||||
});
|
||||
|
||||
it('handles plain text without tags', () => {
|
||||
const result = htmlToMarkdown('Just plain text');
|
||||
expect(result.markdown).toContain('Just plain text');
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('handles task list items', () => {
|
||||
const result = htmlToMarkdown(
|
||||
'<ul><li><input type="checkbox" checked /> Done</li><li><input type="checkbox" /> Todo</li></ul>',
|
||||
);
|
||||
expect(result.markdown).toContain('- [x] Done');
|
||||
expect(result.markdown).toContain('- [ ] Todo');
|
||||
});
|
||||
|
||||
it('handles div and span wrappers', () => {
|
||||
const result = htmlToMarkdown('<div><span><p>Content</p></span></div>');
|
||||
expect(result.markdown).toContain('Content');
|
||||
});
|
||||
|
||||
it('handles empty anchor with no text', () => {
|
||||
const result = htmlToMarkdown('<a href="https://example.com"></a>');
|
||||
expect(result.markdown).not.toContain('[');
|
||||
});
|
||||
|
||||
it('handles unknown tags gracefully', () => {
|
||||
const result = htmlToMarkdown('<custom-tag>Content</custom-tag>');
|
||||
expect(result.markdown).toContain('Content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadMarkdownFile', () => {
|
||||
const originalURL = globalThis.URL;
|
||||
let createObjectURLSpy: ReturnType<typeof vi.fn>;
|
||||
let revokeObjectURLSpy: ReturnType<typeof vi.fn>;
|
||||
let clickSpy: ReturnType<typeof vi.fn>;
|
||||
let appendChildSpy: ReturnType<typeof vi.fn>;
|
||||
let removeChildSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
createObjectURLSpy = vi.fn().mockReturnValue('blob:test-url');
|
||||
revokeObjectURLSpy = vi.fn();
|
||||
(globalThis as any).URL = {
|
||||
createObjectURL: createObjectURLSpy,
|
||||
revokeObjectURL: revokeObjectURLSpy,
|
||||
};
|
||||
|
||||
clickSpy = vi.fn();
|
||||
appendChildSpy = vi.fn();
|
||||
removeChildSpy = vi.fn();
|
||||
|
||||
const mockLink = {
|
||||
href: '',
|
||||
download: '',
|
||||
click: clickSpy,
|
||||
};
|
||||
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(mockLink as any);
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation(appendChildSpy as any);
|
||||
vi.spyOn(document.body, 'removeChild').mockImplementation(removeChildSpy as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
(globalThis as any).URL = originalURL;
|
||||
});
|
||||
|
||||
it('should create a blob and trigger download', () => {
|
||||
downloadMarkdownFile('# Hello', 'test.md');
|
||||
|
||||
expect(createObjectURLSpy).toHaveBeenCalledOnce();
|
||||
expect(clickSpy).toHaveBeenCalledOnce();
|
||||
expect(appendChildSpy).toHaveBeenCalledOnce();
|
||||
expect(removeChildSpy).toHaveBeenCalledOnce();
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should use default filename when not provided', () => {
|
||||
downloadMarkdownFile('content');
|
||||
|
||||
const mockLink = (document.createElement as any).mock.results[0].value;
|
||||
expect(mockLink.download).toBe('export.md');
|
||||
});
|
||||
|
||||
it('should set correct MIME type', () => {
|
||||
downloadMarkdownFile('content');
|
||||
|
||||
const blobArg = createObjectURLSpy.mock.calls[0][0] as Blob;
|
||||
expect(blobArg.type).toBe('text/markdown;charset=utf-8');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatJson, validateJson, type JsonFormatOptions } from '../jsonFormatter';
|
||||
|
||||
describe('formatJson', () => {
|
||||
const defaultOptions: JsonFormatOptions = { indentSize: 2, sortKeys: false };
|
||||
|
||||
it('should format minified JSON', () => {
|
||||
const input = '{"name":"test","value":123}';
|
||||
const result = formatJson(input, defaultOptions);
|
||||
expect(result.formatted).toBe('{\n "name": "test",\n "value": 123\n}');
|
||||
expect(result.originalBytes).toBeGreaterThan(0);
|
||||
expect(result.formattedBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return empty result for empty input', () => {
|
||||
const result = formatJson('', defaultOptions);
|
||||
expect(result.formatted).toBe('');
|
||||
expect(result.originalBytes).toBe(0);
|
||||
expect(result.formattedBytes).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty result for whitespace-only input', () => {
|
||||
const result = formatJson(' ', defaultOptions);
|
||||
expect(result.formatted).toBe('');
|
||||
});
|
||||
|
||||
it('should format with custom indent size', () => {
|
||||
const input = '{"a":1}';
|
||||
const result4 = formatJson(input, { indentSize: 4, sortKeys: false });
|
||||
expect(result4.formatted).toBe('{\n "a": 1\n}');
|
||||
|
||||
const result8 = formatJson(input, { indentSize: 8, sortKeys: false });
|
||||
expect(result8.formatted).toBe('{\n "a": 1\n}');
|
||||
});
|
||||
|
||||
it('should sort keys alphabetically when sortKeys is true', () => {
|
||||
const input = '{"c":3,"a":1,"b":2}';
|
||||
const result = formatJson(input, { indentSize: 2, sortKeys: true });
|
||||
expect(result.formatted).toBe('{\n "a": 1,\n "b": 2,\n "c": 3\n}');
|
||||
});
|
||||
|
||||
it('should sort nested object keys recursively', () => {
|
||||
const input = '{"z":1,"a":{"d":4,"b":2,"c":3}}';
|
||||
const result = formatJson(input, { indentSize: 2, sortKeys: true });
|
||||
expect(result.formatted).toBe(
|
||||
'{\n "a": {\n "b": 2,\n "c": 3,\n "d": 4\n },\n "z": 1\n}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not sort keys by default', () => {
|
||||
const input = '{"c":3,"a":1,"b":2}';
|
||||
const result = formatJson(input, defaultOptions);
|
||||
expect(result.formatted).toBe('{\n "c": 3,\n "a": 1,\n "b": 2\n}');
|
||||
});
|
||||
|
||||
it('should handle arrays correctly', () => {
|
||||
const input = '[1,2,3]';
|
||||
const result = formatJson(input, defaultOptions);
|
||||
expect(result.formatted).toBe('[\n 1,\n 2,\n 3\n]');
|
||||
});
|
||||
|
||||
it('should handle nested arrays and objects', () => {
|
||||
const input = '{"users":[{"name":"Alice"},{"name":"Bob"}]}';
|
||||
const result = formatJson(input, defaultOptions);
|
||||
expect(result.formatted).toContain('"users"');
|
||||
expect(result.formatted).toContain('"Alice"');
|
||||
expect(result.formatted).toContain('"Bob"');
|
||||
});
|
||||
|
||||
it('should handle primitive values', () => {
|
||||
expect(formatJson('null', defaultOptions).formatted).toBe('null');
|
||||
expect(formatJson('true', defaultOptions).formatted).toBe('true');
|
||||
expect(formatJson('42', defaultOptions).formatted).toBe('42');
|
||||
expect(formatJson('"hello"', defaultOptions).formatted).toBe('"hello"');
|
||||
});
|
||||
|
||||
it('should throw SyntaxError for invalid JSON', () => {
|
||||
expect(() => formatJson('{invalid}', defaultOptions)).toThrow(SyntaxError);
|
||||
});
|
||||
|
||||
it('should calculate byte sizes correctly', () => {
|
||||
const input = '{"a":1}';
|
||||
const result = formatJson(input, defaultOptions);
|
||||
// ASCII characters: each character = 1 byte
|
||||
expect(result.originalBytes).toBe(input.trim().length);
|
||||
expect(result.formattedBytes).toBe(result.formatted.length);
|
||||
});
|
||||
|
||||
it('should handle JSON with Unicode characters', () => {
|
||||
const input = '{"name":"测试"}';
|
||||
const result = formatJson(input, defaultOptions);
|
||||
expect(result.formatted).toContain('"测试"');
|
||||
// UTF-8: each Chinese character is 3 bytes
|
||||
expect(result.originalBytes).toBeGreaterThan(input.trim().length);
|
||||
});
|
||||
|
||||
it('should sort keys in arrays containing objects', () => {
|
||||
const input = '[{"z":1,"a":2}]';
|
||||
const result = formatJson(input, { indentSize: 2, sortKeys: true });
|
||||
expect(result.formatted).toContain('"a": 2');
|
||||
expect(result.formatted).toContain('"z": 1');
|
||||
// Ensure sorted order
|
||||
const aIndex = result.formatted.indexOf('"a": 2');
|
||||
const zIndex = result.formatted.indexOf('"z": 1');
|
||||
expect(aIndex).toBeLessThan(zIndex);
|
||||
});
|
||||
|
||||
it('should handle already formatted JSON', () => {
|
||||
const input = '{\n "name": "test",\n "value": 123\n}';
|
||||
const result = formatJson(input, defaultOptions);
|
||||
expect(result.formatted).toBe('{\n "name": "test",\n "value": 123\n}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateJson', () => {
|
||||
it('should return null for valid JSON object', () => {
|
||||
expect(validateJson('{"a":1}')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for valid JSON array', () => {
|
||||
expect(validateJson('[1,2,3]')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for valid JSON primitive', () => {
|
||||
expect(validateJson('null')).toBeNull();
|
||||
expect(validateJson('true')).toBeNull();
|
||||
expect(validateJson('42')).toBeNull();
|
||||
expect(validateJson('"hello"')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty input', () => {
|
||||
expect(validateJson('')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for whitespace-only input', () => {
|
||||
expect(validateJson(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error message for invalid JSON', () => {
|
||||
const error = validateJson('{invalid}');
|
||||
expect(error).not.toBeNull();
|
||||
expect(typeof error).toBe('string');
|
||||
});
|
||||
|
||||
it('should return error for unclosed bracket', () => {
|
||||
expect(validateJson('{"a":1')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should return error for trailing comma', () => {
|
||||
expect(validateJson('{"a":1,}')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should handle JSON with leading/trailing whitespace', () => {
|
||||
expect(validateJson(' {"a":1} ')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { minifyJson } from '../jsonFormatter';
|
||||
|
||||
describe('minifyJson', () => {
|
||||
it('should minify formatted JSON', () => {
|
||||
const input = '{\n "name": "test",\n "value": 123\n}';
|
||||
const result = minifyJson(input);
|
||||
expect(result.minified).toBe('{"name":"test","value":123}');
|
||||
});
|
||||
|
||||
it('should return empty result for empty input', () => {
|
||||
const result = minifyJson('');
|
||||
expect(result.minified).toBe('');
|
||||
expect(result.originalBytes).toBe(0);
|
||||
expect(result.minifiedBytes).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty result for whitespace-only input', () => {
|
||||
const result = minifyJson(' ');
|
||||
expect(result.minified).toBe('');
|
||||
});
|
||||
|
||||
it('should handle already minified JSON', () => {
|
||||
const input = '{"a":1}';
|
||||
const result = minifyJson(input);
|
||||
expect(result.minified).toBe('{"a":1}');
|
||||
});
|
||||
|
||||
it('should remove all whitespace', () => {
|
||||
const input = '{\n "key" : "value" ,\n "num" : 42\n}';
|
||||
const result = minifyJson(input);
|
||||
expect(result.minified).toBe('{"key":"value","num":42}');
|
||||
});
|
||||
|
||||
it('should preserve string content with spaces', () => {
|
||||
const input = '{"message":"hello world"}';
|
||||
const result = minifyJson(input);
|
||||
expect(result.minified).toBe('{"message":"hello world"}');
|
||||
});
|
||||
|
||||
it('should handle arrays', () => {
|
||||
const input = '[\n 1,\n 2,\n 3\n]';
|
||||
const result = minifyJson(input);
|
||||
expect(result.minified).toBe('[1,2,3]');
|
||||
});
|
||||
|
||||
it('should handle nested objects', () => {
|
||||
const input = '{\n "a": {\n "b": 1\n }\n}';
|
||||
const result = minifyJson(input);
|
||||
expect(result.minified).toBe('{"a":{"b":1}}');
|
||||
});
|
||||
|
||||
it('should handle primitive values', () => {
|
||||
expect(minifyJson('null').minified).toBe('null');
|
||||
expect(minifyJson('true').minified).toBe('true');
|
||||
expect(minifyJson('42').minified).toBe('42');
|
||||
expect(minifyJson('"hello"').minified).toBe('"hello"');
|
||||
});
|
||||
|
||||
it('should throw SyntaxError for invalid JSON', () => {
|
||||
expect(() => minifyJson('{invalid}')).toThrow(SyntaxError);
|
||||
});
|
||||
|
||||
it('should calculate byte sizes correctly', () => {
|
||||
const input = '{\n "a": 1\n}';
|
||||
const result = minifyJson(input);
|
||||
expect(result.originalBytes).toBeGreaterThan(0);
|
||||
expect(result.minifiedBytes).toBeGreaterThan(0);
|
||||
// Minified should be smaller than original for formatted input
|
||||
expect(result.minifiedBytes).toBeLessThan(result.originalBytes);
|
||||
});
|
||||
|
||||
it('should handle Unicode content', () => {
|
||||
const input = '{\n "名前": "テスト"\n}';
|
||||
const result = minifyJson(input);
|
||||
expect(result.minified).toBe('{"名前":"テスト"}');
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const result = minifyJson('{}');
|
||||
expect(result.minified).toBe('{}');
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const result = minifyJson('[]');
|
||||
expect(result.minified).toBe('[]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { jsonToToml } from '../jsonToToml';
|
||||
|
||||
describe('jsonToToml', () => {
|
||||
it('should convert a simple object', () => {
|
||||
const result = jsonToToml('{"name":"test","value":123}');
|
||||
expect(result.output).toContain('name = "test"');
|
||||
expect(result.output).toContain('value = 123');
|
||||
});
|
||||
|
||||
it('should convert a nested object with table headers', () => {
|
||||
const result = jsonToToml('{"database":{"host":"localhost","port":5432}}');
|
||||
expect(result.output).toContain('[database]');
|
||||
expect(result.output).toContain('host = "localhost"');
|
||||
expect(result.output).toContain('port = 5432');
|
||||
});
|
||||
|
||||
it('should handle boolean values', () => {
|
||||
const result = jsonToToml('{"active":true,"deleted":false}');
|
||||
expect(result.output).toContain('active = true');
|
||||
expect(result.output).toContain('deleted = false');
|
||||
});
|
||||
|
||||
it('should handle null values as empty string', () => {
|
||||
const result = jsonToToml('{"key":null}');
|
||||
expect(result.output).toContain('key = ""');
|
||||
});
|
||||
|
||||
it('should handle number values', () => {
|
||||
const result = jsonToToml('{"count":42,"price":3.14}');
|
||||
expect(result.output).toContain('count = 42');
|
||||
expect(result.output).toContain('price = 3.14');
|
||||
});
|
||||
|
||||
it('should handle arrays of primitives', () => {
|
||||
const result = jsonToToml('{"ports":[80,443,8080]}');
|
||||
expect(result.output).toContain('ports = [80, 443, 8080]');
|
||||
});
|
||||
|
||||
it('should handle arrays of strings', () => {
|
||||
const result = jsonToToml('{"tags":["web","api"]}');
|
||||
expect(result.output).toContain('tags = ["web", "api"]');
|
||||
});
|
||||
|
||||
it('should handle object arrays with table array syntax', () => {
|
||||
const result = jsonToToml('{"users":[{"name":"Alice"},{"name":"Bob"}]}');
|
||||
expect(result.output).toContain('[[users]]');
|
||||
expect(result.output).toContain('name = "Alice"');
|
||||
expect(result.output).toContain('name = "Bob"');
|
||||
});
|
||||
|
||||
it('should handle deeply nested objects', () => {
|
||||
const result = jsonToToml('{"a":{"b":{"c":1}}}');
|
||||
expect(result.output).toContain('[a.b]');
|
||||
expect(result.output).toContain('c = 1');
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const result = jsonToToml('{}');
|
||||
expect(result.output).toBe('');
|
||||
});
|
||||
|
||||
it('should handle empty string input', () => {
|
||||
const result = jsonToToml('');
|
||||
expect(result.output).toBe('');
|
||||
expect(result.originalBytes).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate byte sizes', () => {
|
||||
const result = jsonToToml('{"a":1}');
|
||||
expect(result.originalBytes).toBeGreaterThan(0);
|
||||
expect(result.outputBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should throw SyntaxError for invalid JSON', () => {
|
||||
expect(() => jsonToToml('{invalid}')).toThrow(SyntaxError);
|
||||
});
|
||||
|
||||
it('should throw Error for non-object top-level value', () => {
|
||||
expect(() => jsonToToml('"hello"')).toThrow(
|
||||
'TOML requires the top-level value to be an object',
|
||||
);
|
||||
expect(() => jsonToToml('[1,2]')).toThrow('TOML requires the top-level value to be an object');
|
||||
expect(() => jsonToToml('null')).toThrow('TOML requires the top-level value to be an object');
|
||||
});
|
||||
|
||||
it('should escape special characters in strings', () => {
|
||||
const result = jsonToToml('{"path":"C:\\\\Users\\\\test"}');
|
||||
expect(result.output).toContain('"C:\\\\Users\\\\test"');
|
||||
});
|
||||
|
||||
it('should handle keys with special characters', () => {
|
||||
const result = jsonToToml('{"key with spaces":"value"}');
|
||||
expect(result.output).toContain('"key with spaces"');
|
||||
});
|
||||
|
||||
it('should handle empty arrays of primitives', () => {
|
||||
const result = jsonToToml('{"items":[]}');
|
||||
expect(result.output).toContain('items = []');
|
||||
});
|
||||
|
||||
it('should separate table sections with blank lines', () => {
|
||||
const result = jsonToToml('{"a":{"x":1},"b":{"y":2}}');
|
||||
expect(result.output).toContain('\n\n');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { jsonToYaml } from '../jsonToYaml';
|
||||
|
||||
describe('jsonToYaml', () => {
|
||||
it('should convert a simple object', () => {
|
||||
const result = jsonToYaml('{"name":"test","value":123}');
|
||||
expect(result.output).toBe('name: test\nvalue: 123');
|
||||
});
|
||||
|
||||
it('should convert a nested object', () => {
|
||||
const result = jsonToYaml('{"user":{"name":"Alice","age":30}}');
|
||||
expect(result.output).toBe('user:\n name: Alice\n age: 30');
|
||||
});
|
||||
|
||||
it('should convert an array of primitives', () => {
|
||||
const result = jsonToYaml('[1,2,3]');
|
||||
expect(result.output).toBe('- 1\n- 2\n- 3');
|
||||
});
|
||||
|
||||
it('should convert an array of objects', () => {
|
||||
const result = jsonToYaml('[{"name":"A"},{"name":"B"}]');
|
||||
expect(result.output).toContain('name: A');
|
||||
expect(result.output).toContain('name: B');
|
||||
expect(result.output).toContain('- name: A');
|
||||
expect(result.output).toContain('- name: B');
|
||||
});
|
||||
|
||||
it('should handle null values', () => {
|
||||
const result = jsonToYaml('{"key":null}');
|
||||
expect(result.output).toBe('key: null');
|
||||
});
|
||||
|
||||
it('should handle boolean values', () => {
|
||||
const result = jsonToYaml('{"active":true,"deleted":false}');
|
||||
expect(result.output).toBe('active: true\ndeleted: false');
|
||||
});
|
||||
|
||||
it('should handle number values', () => {
|
||||
const result = jsonToYaml('{"count":42,"price":3.14}');
|
||||
expect(result.output).toBe('count: 42\nprice: 3.14');
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const result = jsonToYaml('{}');
|
||||
expect(result.output).toBe('{}');
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const result = jsonToYaml('[]');
|
||||
expect(result.output).toContain('[]');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const result = jsonToYaml('');
|
||||
expect(result.output).toBe('');
|
||||
expect(result.originalBytes).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle whitespace-only input', () => {
|
||||
const result = jsonToYaml(' ');
|
||||
expect(result.output).toBe('');
|
||||
});
|
||||
|
||||
it('should quote strings with special characters', () => {
|
||||
const result = jsonToYaml('{"key":"value: with colon"}');
|
||||
expect(result.output).toContain('"value: with colon"');
|
||||
});
|
||||
|
||||
it('should quote strings that look like YAML keywords', () => {
|
||||
const result = jsonToYaml('{"key":"null"}');
|
||||
expect(result.output).toContain('"null"');
|
||||
});
|
||||
|
||||
it('should handle deeply nested objects', () => {
|
||||
const result = jsonToYaml('{"a":{"b":{"c":1}}}');
|
||||
expect(result.output).toBe('a:\n b:\n c: 1');
|
||||
});
|
||||
|
||||
it('should calculate byte sizes', () => {
|
||||
const result = jsonToYaml('{"a":1}');
|
||||
expect(result.originalBytes).toBeGreaterThan(0);
|
||||
expect(result.outputBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should throw SyntaxError for invalid JSON', () => {
|
||||
expect(() => jsonToYaml('{invalid}')).toThrow(SyntaxError);
|
||||
});
|
||||
|
||||
it('should handle keys with special characters', () => {
|
||||
const result = jsonToYaml('{"key with spaces":"value"}');
|
||||
expect(result.output).toContain('"key with spaces"');
|
||||
});
|
||||
|
||||
it('should handle nested arrays in objects', () => {
|
||||
const result = jsonToYaml('{"items":[1,2,3]}');
|
||||
expect(result.output).toContain('items:');
|
||||
expect(result.output).toContain('- 1');
|
||||
expect(result.output).toContain('- 2');
|
||||
expect(result.output).toContain('- 3');
|
||||
});
|
||||
|
||||
it('should handle primitive top-level values', () => {
|
||||
expect(jsonToYaml('"hello"').output).toBe('hello');
|
||||
expect(jsonToYaml('42').output).toBe('42');
|
||||
expect(jsonToYaml('true').output).toBe('true');
|
||||
expect(jsonToYaml('null').output).toBe('null');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { decodeBase64Url, parseJwt } from '@/utils/jwt';
|
||||
|
||||
describe('jwt utils', () => {
|
||||
describe('decodeBase64Url', () => {
|
||||
it('should decode standard base64url', () => {
|
||||
// "test" -> "dGVzdA"
|
||||
expect(decodeBase64Url('dGVzdA')).toBe('test');
|
||||
});
|
||||
|
||||
it('should handle padding correctly', () => {
|
||||
// "a" -> "YQ" (needs ==)
|
||||
expect(decodeBase64Url('YQ')).toBe('a');
|
||||
// "ab" -> "YWI" (needs =)
|
||||
expect(decodeBase64Url('YWI')).toBe('ab');
|
||||
});
|
||||
|
||||
it('should handle - and _ correctly', () => {
|
||||
// Validating base64url specific chars
|
||||
// standard base64 of binary 0xFF 0xEF is "/+8="
|
||||
// base64url should be "_-8"
|
||||
// Wait, let's use a simpler one.
|
||||
// 0xFB 0xFF -> "+/8=" in base64, "-_8=" in base64url? No.
|
||||
// + -> -
|
||||
// / -> _
|
||||
// let's try to encode something that results in + and /
|
||||
// binary 0xFB 0xFF 0xBE -> "+/++" in base64 -> "-_--" in base64url
|
||||
expect(decodeBase64Url('-_--')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should decode UTF-8 characters correctly', () => {
|
||||
// "你好" -> "5L2g5aW9"
|
||||
expect(decodeBase64Url('5L2g5aW9')).toBe('你好');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseJwt', () => {
|
||||
it('should return error for invalid format', () => {
|
||||
const result = parseJwt('invalid-token');
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should parse a valid JWT structure', () => {
|
||||
// Header: {"alg":"HS256","typ":"JWT"}
|
||||
// Payload: {"sub":"1234567890","name":"John Doe","iat":1516239022}
|
||||
const token =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
|
||||
const result = parseJwt(token);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.header?.alg).toBe('HS256');
|
||||
expect(result.payload?.name).toBe('John Doe');
|
||||
expect(result.signature).toBe('SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c');
|
||||
});
|
||||
|
||||
it('should handle malformed json in header/payload', () => {
|
||||
// Base64 of "{"
|
||||
const token = 'ew.ew.signature';
|
||||
const result = parseJwt(token);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
markdownToHtml,
|
||||
wrapHtmlDocument,
|
||||
downloadHtmlFile,
|
||||
SAMPLE_MARKDOWN,
|
||||
} from '@/utils/markdownToHtml';
|
||||
|
||||
describe('markdownToHtml', () => {
|
||||
it('应该转换基础 Markdown 标题', () => {
|
||||
const result = markdownToHtml('# Hello World');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<h1');
|
||||
expect(result.html).toContain('Hello World');
|
||||
expect(result.originalLength).toBe(13);
|
||||
expect(result.htmlLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('应该转换粗体和斜体', () => {
|
||||
const result = markdownToHtml('**bold** and *italic*');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<strong>bold</strong>');
|
||||
expect(result.html).toContain('<em>italic</em>');
|
||||
});
|
||||
|
||||
it('应该转换链接', () => {
|
||||
const result = markdownToHtml('[Google](https://google.com)');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<a');
|
||||
expect(result.html).toContain('href="https://google.com"');
|
||||
expect(result.html).toContain('Google');
|
||||
});
|
||||
|
||||
it('应该转换无序列表', () => {
|
||||
const result = markdownToHtml('- item 1\n- item 2');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<ul>');
|
||||
expect(result.html).toContain('<li>item 1</li>');
|
||||
});
|
||||
|
||||
it('应该转换有序列表', () => {
|
||||
const result = markdownToHtml('1. first\n2. second');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<ol>');
|
||||
expect(result.html).toContain('<li>first</li>');
|
||||
});
|
||||
|
||||
it('应该转换代码块', () => {
|
||||
const result = markdownToHtml('```js\nconst x = 1;\n```');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<pre>');
|
||||
expect(result.html).toContain('<code');
|
||||
expect(result.html).toContain('const x = 1;');
|
||||
});
|
||||
|
||||
it('应该转换行内代码', () => {
|
||||
const result = markdownToHtml('use `npm install` command');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<code>npm install</code>');
|
||||
});
|
||||
|
||||
it('应该转换引用块', () => {
|
||||
const result = markdownToHtml('> This is a quote');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<blockquote>');
|
||||
expect(result.html).toContain('This is a quote');
|
||||
});
|
||||
|
||||
it('应该转换表格', () => {
|
||||
const md = '| A | B |\n|---|---|\n| 1 | 2 |';
|
||||
const result = markdownToHtml(md);
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<table>');
|
||||
expect(result.html).toContain('<th>A</th>');
|
||||
expect(result.html).toContain('<td>1</td>');
|
||||
});
|
||||
|
||||
it('应该转换任务列表', () => {
|
||||
const result = markdownToHtml('- [x] done\n- [ ] todo');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<input');
|
||||
expect(result.html).toContain('checked');
|
||||
});
|
||||
|
||||
it('应该转换删除线', () => {
|
||||
const result = markdownToHtml('~~deleted~~');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<del>deleted</del>');
|
||||
});
|
||||
|
||||
it('应该处理空字符串', () => {
|
||||
const result = markdownToHtml('');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toBe('');
|
||||
expect(result.originalLength).toBe(0);
|
||||
expect(result.htmlLength).toBe(0);
|
||||
});
|
||||
|
||||
it('应该处理空白字符串', () => {
|
||||
const result = markdownToHtml(' \n ');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toBe('');
|
||||
});
|
||||
|
||||
it('应该处理中文内容', () => {
|
||||
const result = markdownToHtml('# 你好世界\n\n这是**中文**内容。');
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('你好世界');
|
||||
expect(result.html).toContain('<strong>中文</strong>');
|
||||
});
|
||||
|
||||
it('应该转换示例 Markdown', () => {
|
||||
const result = markdownToHtml(SAMPLE_MARKDOWN);
|
||||
expect(result.hasError).toBe(false);
|
||||
expect(result.html).toContain('<h1');
|
||||
expect(result.html).toContain('<h2');
|
||||
expect(result.html).toContain('<table>');
|
||||
expect(result.html).toContain('<code>');
|
||||
expect(result.originalLength).toBe(SAMPLE_MARKDOWN.length);
|
||||
expect(result.htmlLength).toBeGreaterThan(result.originalLength);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapHtmlDocument', () => {
|
||||
it('应该生成完整的 HTML 文档', () => {
|
||||
const doc = wrapHtmlDocument('<p>Hello</p>', 'Test Title');
|
||||
expect(doc).toContain('<!DOCTYPE html>');
|
||||
expect(doc).toContain('<html');
|
||||
expect(doc).toContain('<head>');
|
||||
expect(doc).toContain('<title>Test Title</title>');
|
||||
expect(doc).toContain('<body>');
|
||||
expect(doc).toContain('<p>Hello</p>');
|
||||
expect(doc).toContain('</html>');
|
||||
});
|
||||
|
||||
it('应该转义标题中的特殊字符', () => {
|
||||
const doc = wrapHtmlDocument('<p>test</p>', 'Title <script>');
|
||||
expect(doc).toContain('Title <script>');
|
||||
expect(doc).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('应该使用默认标题', () => {
|
||||
const doc = wrapHtmlDocument('<p>test</p>');
|
||||
expect(doc).toContain('<title>Markdown Export</title>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadHtmlFile', () => {
|
||||
it('应该创建下载链接并触发下载', () => {
|
||||
const createObjectURLSpy = vi.fn(() => 'blob:test');
|
||||
const revokeObjectURLSpy = vi.fn();
|
||||
(globalThis as any).URL = {
|
||||
createObjectURL: createObjectURLSpy,
|
||||
revokeObjectURL: revokeObjectURLSpy,
|
||||
};
|
||||
|
||||
const clickSpy = vi.fn();
|
||||
const mockAnchor = document.createElement('a');
|
||||
mockAnchor.click = clickSpy;
|
||||
|
||||
const createElementSpy = vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor);
|
||||
const appendChildSpy = vi.spyOn(document.body, 'appendChild').mockReturnValue(mockAnchor);
|
||||
const removeChildSpy = vi.spyOn(document.body, 'removeChild').mockReturnValue(mockAnchor);
|
||||
|
||||
downloadHtmlFile('<p>test</p>', 'test.html');
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
expect(appendChildSpy).toHaveBeenCalled();
|
||||
expect(removeChildSpy).toHaveBeenCalled();
|
||||
expect(createObjectURLSpy).toHaveBeenCalled();
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalled();
|
||||
|
||||
createElementSpy.mockRestore();
|
||||
appendChildSpy.mockRestore();
|
||||
removeChildSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { sendMessageToContent, MessageAction } from '@/utils/messages';
|
||||
|
||||
// Mock @webext-core/messaging - vi.mock is hoisted, so we define mock inside factory
|
||||
vi.mock('@webext-core/messaging', () => {
|
||||
const mockSendMessage = vi.fn();
|
||||
return {
|
||||
defineExtensionMessaging: () => ({
|
||||
sendMessage: mockSendMessage,
|
||||
onMessage: vi.fn(),
|
||||
}),
|
||||
// Export the mock so we can access it in tests
|
||||
__mockSendMessage: mockSendMessage,
|
||||
};
|
||||
});
|
||||
|
||||
// Helper to get the mock function from the mocked module
|
||||
async function getMockSendMessage() {
|
||||
const mod = await import('@webext-core/messaging');
|
||||
return (mod as any).__mockSendMessage;
|
||||
}
|
||||
|
||||
describe('messages', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('sendMessageToContent', () => {
|
||||
it('应该成功发送消息到内容脚本并返回响应', async () => {
|
||||
const mockSendMessage = await getMockSendMessage();
|
||||
const mockResponse = { success: true };
|
||||
mockSendMessage.mockResolvedValue(mockResponse);
|
||||
|
||||
const mockTab = { id: 123, url: 'https://example.com' };
|
||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||
|
||||
const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(MessageAction.RELOAD_TAB, { tabId: 123 }, 123);
|
||||
});
|
||||
|
||||
it('应该支持不带数据的消息发送', async () => {
|
||||
const mockSendMessage = await getMockSendMessage();
|
||||
mockSendMessage.mockResolvedValue(undefined);
|
||||
|
||||
const mockTab = { id: 456, url: 'https://example.com' };
|
||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||
|
||||
await sendMessageToContent(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true });
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(
|
||||
MessageAction.SIDE_PANEL_STATE_CHANGED,
|
||||
{ isOpen: true },
|
||||
456,
|
||||
);
|
||||
});
|
||||
|
||||
it('当无法获取当前标签页时应返回错误', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
(chrome.tabs.query as any).mockResolvedValue([]);
|
||||
|
||||
const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
|
||||
|
||||
expect(result).toEqual({ success: false, message: '无法获取当前标签页' });
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[Messaging] 无法获取当前标签页,无法发送动作: reloadTab',
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('当标签页没有 id 时应返回错误', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
(chrome.tabs.query as any).mockResolvedValue([{ url: 'https://example.com' }]);
|
||||
|
||||
const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
|
||||
|
||||
expect(result).toEqual({ success: false, message: '无法获取当前标签页' });
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('当连接无法建立时应返回特定错误消息', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const mockSendMessage = await getMockSendMessage();
|
||||
mockSendMessage.mockRejectedValue(new Error('Could not establish connection'));
|
||||
|
||||
const mockTab = { id: 123, url: 'https://example.com' };
|
||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||
|
||||
const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: '无法连接到网页,请刷新页面后再试',
|
||||
});
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('当响应超时时应返回特定错误消息', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const mockSendMessage = await getMockSendMessage();
|
||||
mockSendMessage.mockRejectedValue(new Error('No response received'));
|
||||
|
||||
const mockTab = { id: 123, url: 'https://example.com' };
|
||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||
|
||||
const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: '网页响应超时,请重试',
|
||||
});
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('当发生其他错误时应返回通用错误消息', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const mockSendMessage = await getMockSendMessage();
|
||||
mockSendMessage.mockRejectedValue(new Error('Unknown error'));
|
||||
|
||||
const mockTab = { id: 123, url: 'https://example.com' };
|
||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||
|
||||
const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: '通信失败: Unknown error',
|
||||
});
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('当错误不是 Error 实例时应正确处理字符串错误', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const mockSendMessage = await getMockSendMessage();
|
||||
mockSendMessage.mockRejectedValue('string error');
|
||||
|
||||
const mockTab = { id: 123, url: 'https://example.com' };
|
||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
||||
|
||||
const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: '通信失败: string error',
|
||||
});
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('当 tabs.query 失败时应返回错误', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
(chrome.tabs.query as any).mockRejectedValue(new Error('Query failed'));
|
||||
|
||||
const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: '通信失败: Query failed',
|
||||
});
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
||||
import QrScanner from 'qr-scanner';
|
||||
|
||||
// Mock qr-scanner
|
||||
vi.mock('qr-scanner', () => ({
|
||||
default: {
|
||||
scanImage: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('qrCodeParser', () => {
|
||||
describe('parseQrCodeFromFile', () => {
|
||||
it('应该成功解析二维码并返回数据', async () => {
|
||||
const mockResult = { data: 'https://example.com', cornerPoints: [] };
|
||||
(QrScanner.scanImage as any).mockResolvedValue(mockResult);
|
||||
|
||||
const mockFile = new File(['mock-image-data'], 'qrcode.png', { type: 'image/png' });
|
||||
const result = await parseQrCodeFromFile(mockFile);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toBe('https://example.com');
|
||||
expect(QrScanner.scanImage).toHaveBeenCalledWith(mockFile, {
|
||||
returnDetailedScanResult: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('当未检测到二维码时应返回错误', async () => {
|
||||
const mockResult = { data: '', cornerPoints: [] };
|
||||
(QrScanner.scanImage as any).mockResolvedValue(mockResult);
|
||||
|
||||
const mockFile = new File(['mock-image-data'], 'no-qr.png', { type: 'image/png' });
|
||||
const result = await parseQrCodeFromFile(mockFile);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('未检测到二维码');
|
||||
});
|
||||
|
||||
it('当 scanImage 返回 null 时应返回错误', async () => {
|
||||
(QrScanner.scanImage as any).mockResolvedValue(null);
|
||||
|
||||
const mockFile = new File(['mock-image-data'], 'empty.png', { type: 'image/png' });
|
||||
const result = await parseQrCodeFromFile(mockFile);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('未检测到二维码');
|
||||
});
|
||||
|
||||
it('当抛出 "No QR code found" 时应返回中文错误', async () => {
|
||||
(QrScanner.scanImage as any).mockRejectedValue('No QR code found');
|
||||
|
||||
const mockFile = new File(['mock-image-data'], 'no-qr.png', { type: 'image/png' });
|
||||
const result = await parseQrCodeFromFile(mockFile);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('未检测到二维码');
|
||||
});
|
||||
|
||||
it('当抛出 Error 实例时应返回错误消息', async () => {
|
||||
(QrScanner.scanImage as any).mockRejectedValue(new Error('Image format not supported'));
|
||||
|
||||
const mockFile = new File(['mock-image-data'], 'bad.png', { type: 'image/png' });
|
||||
const result = await parseQrCodeFromFile(mockFile);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Image format not supported');
|
||||
});
|
||||
|
||||
it('当抛出非 Error 非字符串值时应正确转换', async () => {
|
||||
(QrScanner.scanImage as any).mockRejectedValue(12345);
|
||||
|
||||
const mockFile = new File(['mock-image-data'], 'error.png', { type: 'image/png' });
|
||||
const result = await parseQrCodeFromFile(mockFile);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('12345');
|
||||
});
|
||||
|
||||
it('当抛出对象时应正确转换为字符串', async () => {
|
||||
(QrScanner.scanImage as any).mockRejectedValue({ message: 'custom error' });
|
||||
|
||||
const mockFile = new File(['mock-image-data'], 'error.png', { type: 'image/png' });
|
||||
const result = await parseQrCodeFromFile(mockFile);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('[object Object]');
|
||||
});
|
||||
|
||||
it('应该处理包含中文内容的二维码', async () => {
|
||||
const mockResult = { data: 'https://example.com/中文路径', cornerPoints: [] };
|
||||
(QrScanner.scanImage as any).mockResolvedValue(mockResult);
|
||||
|
||||
const mockFile = new File(['mock-image-data'], 'chinese.png', { type: 'image/png' });
|
||||
const result = await parseQrCodeFromFile(mockFile);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toBe('https://example.com/中文路径');
|
||||
});
|
||||
|
||||
it('应该处理纯文本二维码', async () => {
|
||||
const mockResult = { data: 'WIFI:T:WPA;S:MyNetwork;P:password;;', cornerPoints: [] };
|
||||
(QrScanner.scanImage as any).mockResolvedValue(mockResult);
|
||||
|
||||
const mockFile = new File(['mock-image-data'], 'wifi.png', { type: 'image/png' });
|
||||
const result = await parseQrCodeFromFile(mockFile);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toBe('WIFI:T:WPA;S:MyNetwork;P:password;;');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { clearCookies, formatSize, isRestrictedUrl } from '@/utils/storageCleaner';
|
||||
|
||||
describe('storageCleaner utils', () => {
|
||||
describe('isRestrictedUrl', () => {
|
||||
it('should return true for chrome:// URLs', () => {
|
||||
expect(isRestrictedUrl('chrome://settings')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for chrome-extension:// URLs', () => {
|
||||
expect(isRestrictedUrl('chrome-extension://abc123/background.html')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for about:// URLs', () => {
|
||||
expect(isRestrictedUrl('about:blank')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for edge:// URLs', () => {
|
||||
expect(isRestrictedUrl('edge://settings')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for view-source:// URLs', () => {
|
||||
expect(isRestrictedUrl('view-source:https://example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for file:// URLs', () => {
|
||||
expect(isRestrictedUrl('file:///path/to/file')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for data:// URLs', () => {
|
||||
expect(isRestrictedUrl('data:text/html,<h1>Hello</h1>')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for http:// URLs', () => {
|
||||
expect(isRestrictedUrl('http://example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for https:// URLs', () => {
|
||||
expect(isRestrictedUrl('https://example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for undefined URL', () => {
|
||||
expect(isRestrictedUrl(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for empty string', () => {
|
||||
expect(isRestrictedUrl('')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSize', () => {
|
||||
it('should return "0 B" for 0 bytes', () => {
|
||||
expect(formatSize(0)).toBe('0 B');
|
||||
});
|
||||
|
||||
it('should format bytes correctly', () => {
|
||||
expect(formatSize(500)).toBe('500 B');
|
||||
});
|
||||
|
||||
it('should format kilobytes correctly', () => {
|
||||
expect(formatSize(1024)).toBe('1.0 KB');
|
||||
expect(formatSize(1536)).toBe('1.5 KB');
|
||||
expect(formatSize(2048)).toBe('2.0 KB');
|
||||
});
|
||||
|
||||
it('should format megabytes correctly', () => {
|
||||
expect(formatSize(1048576)).toBe('1.00 MB');
|
||||
expect(formatSize(1572864)).toBe('1.50 MB');
|
||||
expect(formatSize(5242880)).toBe('5.00 MB');
|
||||
});
|
||||
|
||||
it('should format gigabytes correctly', () => {
|
||||
expect(formatSize(1073741824)).toBe('1.00 GB');
|
||||
expect(formatSize(2147483648)).toBe('2.00 GB');
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(formatSize(1)).toBe('1 B');
|
||||
expect(formatSize(1023)).toBe('1023 B');
|
||||
expect(formatSize(1025)).toBe('1.0 KB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearCookies', () => {
|
||||
it('should strip leading dot from cookie domain when removing cookies', async () => {
|
||||
const mockCookies = [
|
||||
{ name: 'session', domain: '.example.com', path: '/', secure: true, storeId: '0' },
|
||||
{ name: 'auth', domain: '.example.com', path: '/api', secure: false, storeId: '0' },
|
||||
];
|
||||
(chrome.cookies.getAll as any).mockResolvedValue(mockCookies);
|
||||
(chrome.cookies.remove as any).mockResolvedValue(undefined);
|
||||
|
||||
const result = await clearCookies('https://example.com');
|
||||
|
||||
expect(result).toEqual({ success: true, count: 2 });
|
||||
expect(chrome.cookies.remove).toHaveBeenCalledWith({
|
||||
url: 'https://example.com/',
|
||||
name: 'session',
|
||||
storeId: '0',
|
||||
});
|
||||
expect(chrome.cookies.remove).toHaveBeenCalledWith({
|
||||
url: 'http://example.com/api',
|
||||
name: 'auth',
|
||||
storeId: '0',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle domains without leading dot', async () => {
|
||||
const mockCookies = [
|
||||
{ name: 'pref', domain: 'sub.example.com', path: '/path', secure: true, storeId: '0' },
|
||||
];
|
||||
(chrome.cookies.getAll as any).mockResolvedValue(mockCookies);
|
||||
(chrome.cookies.remove as any).mockResolvedValue(undefined);
|
||||
|
||||
const result = await clearCookies('https://sub.example.com');
|
||||
|
||||
expect(result).toEqual({ success: true, count: 1 });
|
||||
expect(chrome.cookies.remove).toHaveBeenCalledWith({
|
||||
url: 'https://sub.example.com/path',
|
||||
name: 'pref',
|
||||
storeId: '0',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when operation fails', async () => {
|
||||
(chrome.cookies.getAll as any).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const result = await clearCookies('https://example.com');
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Error: Permission denied' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatByteSize, getTextStats } from '@/utils/textStatistics';
|
||||
|
||||
describe('textStatistics utils', () => {
|
||||
describe('getTextStats', () => {
|
||||
it('should return zeros for empty text', () => {
|
||||
const stats = getTextStats('');
|
||||
expect(stats).toEqual({ characters: 0, words: 0, lines: 0, bytes: 0 });
|
||||
});
|
||||
|
||||
it('should count characters correctly', () => {
|
||||
expect(getTextStats('abc').characters).toBe(3);
|
||||
expect(getTextStats('a b c').characters).toBe(5);
|
||||
expect(getTextStats('你好').characters).toBe(2);
|
||||
});
|
||||
|
||||
it('should count English words correctly', () => {
|
||||
expect(getTextStats('hello world').words).toBe(2);
|
||||
expect(getTextStats(' hello world ').words).toBe(2);
|
||||
expect(getTextStats('hello, world!').words).toBe(2);
|
||||
});
|
||||
|
||||
it('should count Chinese words correctly', () => {
|
||||
// "你好世界" 在 Intl.Segmenter 中通常被识别为 "你好" 和 "世界" 两个词
|
||||
const stats = getTextStats('你好世界');
|
||||
expect(stats.words).toBe(2);
|
||||
});
|
||||
|
||||
it('should count mixed language words correctly', () => {
|
||||
const stats = getTextStats('Hello 你好');
|
||||
// "Hello" (1) + "你好" (1) = 2
|
||||
expect(stats.words).toBe(2);
|
||||
});
|
||||
|
||||
it('should count lines correctly', () => {
|
||||
expect(getTextStats('line1\nline2').lines).toBe(2);
|
||||
expect(getTextStats('line1\nline2\n').lines).toBe(3);
|
||||
});
|
||||
|
||||
it('should count bytes correctly (UTF-8)', () => {
|
||||
expect(getTextStats('abc').bytes).toBe(3);
|
||||
expect(getTextStats('你好').bytes).toBe(6); // UTF-8 中每个常用汉字占 3 字节
|
||||
});
|
||||
|
||||
it('should handle special cases', () => {
|
||||
expect(getTextStats(' ').words).toBe(0);
|
||||
expect(getTextStats('\n\n\n').lines).toBe(4);
|
||||
expect(getTextStats('\n\n\n').words).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatByteSize', () => {
|
||||
it('should format bytes correctly', () => {
|
||||
expect(formatByteSize(100)).toBe('100 B');
|
||||
expect(formatByteSize(0)).toBe('0 B');
|
||||
expect(formatByteSize(1024)).toBe('1.0 KB');
|
||||
expect(formatByteSize(1024 * 1024)).toBe('1.00 MB');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import {
|
||||
useContextMenuData,
|
||||
saveContextMenuData,
|
||||
clearContextMenuData,
|
||||
} from '@/utils/useContextMenuData';
|
||||
|
||||
describe('useContextMenuData', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('saveContextMenuData', () => {
|
||||
it('应该保存数据到 storage 并添加时间戳', async () => {
|
||||
const data = { featureKey: 'jwt' as const, payload: 'test-token' };
|
||||
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
|
||||
|
||||
await saveContextMenuData(data);
|
||||
|
||||
expect(chrome.storage.local.set).toHaveBeenCalledWith({
|
||||
'contextMenu/pendingData': {
|
||||
featureKey: 'jwt',
|
||||
payload: 'test-token',
|
||||
timestamp: 1704110400000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('应该正确处理不同的 featureKey', async () => {
|
||||
const data = { featureKey: 'timestamp' as const, payload: '1234567890' };
|
||||
|
||||
await saveContextMenuData(data);
|
||||
|
||||
expect(chrome.storage.local.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
'contextMenu/pendingData': expect.objectContaining({
|
||||
featureKey: 'timestamp',
|
||||
payload: '1234567890',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearContextMenuData', () => {
|
||||
it('应该从 storage 中删除数据', async () => {
|
||||
await clearContextMenuData();
|
||||
|
||||
expect(chrome.storage.local.remove).toHaveBeenCalledWith(['contextMenu/pendingData']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useContextMenuData Hook', () => {
|
||||
it('当 storage 中有匹配数据时应调用 onData 回调', async () => {
|
||||
const mockData = {
|
||||
featureKey: 'jwt',
|
||||
payload: 'test-token',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
(chrome.storage.local.get as any).mockResolvedValue({
|
||||
'contextMenu/pendingData': mockData,
|
||||
});
|
||||
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onData).toHaveBeenCalledWith('test-token');
|
||||
});
|
||||
});
|
||||
|
||||
it('当 storage 中没有数据时不应调用 onData 回调', async () => {
|
||||
(chrome.storage.local.get as any).mockResolvedValue({});
|
||||
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('当 featureKey 不匹配时不应调用 onData 回调', async () => {
|
||||
const mockData = {
|
||||
featureKey: 'timestamp',
|
||||
payload: '1234567890',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
(chrome.storage.local.get as any).mockResolvedValue({
|
||||
'contextMenu/pendingData': mockData,
|
||||
});
|
||||
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('当数据过期时不应调用 onData 回调并删除数据', async () => {
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const mockData = {
|
||||
featureKey: 'jwt',
|
||||
payload: 'test-token',
|
||||
timestamp: now - 6000,
|
||||
};
|
||||
(chrome.storage.local.get as any).mockResolvedValue({
|
||||
'contextMenu/pendingData': mockData,
|
||||
});
|
||||
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onData).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.local.remove).toHaveBeenCalledWith(['contextMenu/pendingData']);
|
||||
});
|
||||
});
|
||||
|
||||
it('消费数据后应删除 storage 中的数据', async () => {
|
||||
const mockData = {
|
||||
featureKey: 'jwt',
|
||||
payload: 'test-token',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
(chrome.storage.local.get as any).mockResolvedValue({
|
||||
'contextMenu/pendingData': mockData,
|
||||
});
|
||||
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(chrome.storage.local.remove).toHaveBeenCalledWith(['contextMenu/pendingData']);
|
||||
});
|
||||
});
|
||||
|
||||
it('当 storage 变化且 featureKey 匹配时应调用 onData 回调', async () => {
|
||||
(chrome.storage.local.get as any).mockResolvedValue({});
|
||||
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||
|
||||
const storageChangeHandler = (chrome.storage.onChanged.addListener as any).mock.calls[0][0];
|
||||
|
||||
const mockData = {
|
||||
featureKey: 'jwt',
|
||||
payload: 'new-token',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
(chrome.storage.local.get as any).mockResolvedValue({
|
||||
'contextMenu/pendingData': mockData,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
storageChangeHandler({
|
||||
'contextMenu/pendingData': { newValue: mockData },
|
||||
});
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onData).toHaveBeenCalledWith('new-token');
|
||||
});
|
||||
});
|
||||
|
||||
it('当 storage 变化但 featureKey 不匹配时不应调用 onData 回调', async () => {
|
||||
(chrome.storage.local.get as any).mockResolvedValue({});
|
||||
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||
|
||||
const storageChangeHandler = (chrome.storage.onChanged.addListener as any).mock.calls[0][0];
|
||||
|
||||
const mockData = {
|
||||
featureKey: 'timestamp',
|
||||
payload: '1234567890',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
storageChangeHandler({
|
||||
'contextMenu/pendingData': { newValue: mockData },
|
||||
});
|
||||
});
|
||||
|
||||
expect(onData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('当 storage 变化但数据被删除时不应调用 onData 回调', async () => {
|
||||
(chrome.storage.local.get as any).mockResolvedValue({});
|
||||
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
|
||||
|
||||
const storageChangeHandler = (chrome.storage.onChanged.addListener as any).mock.calls[0][0];
|
||||
|
||||
await act(async () => {
|
||||
storageChangeHandler({
|
||||
'contextMenu/pendingData': { newValue: null },
|
||||
});
|
||||
});
|
||||
|
||||
expect(onData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('组件卸载时应移除 storage 变化监听器', () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useContextMenuData({ featureKey: 'jwt', onData: vi.fn() }),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(chrome.storage.onChanged.removeListener).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { useStorageState } from '@/utils/useStorageState';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import type { JsonToolsPageMode } from '@/types/storage';
|
||||
|
||||
// Mock storageUtil
|
||||
vi.mock('@/utils/chromeStorage', () => ({
|
||||
storageUtil: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('useStorageState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('应该使用默认值初始化', () => {
|
||||
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
||||
Promise.resolve(defaultValue),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStorageState('qrCode/urlExpanded', true));
|
||||
|
||||
// 初始值应为默认值(无快照时)
|
||||
expect(result.current[0]).toBe(true);
|
||||
});
|
||||
|
||||
it('应该从 localStorage 快照同步恢复初始值', () => {
|
||||
localStorage.setItem('snapshot/qrCode/urlExpanded', JSON.stringify(false));
|
||||
|
||||
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
||||
Promise.resolve(defaultValue),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStorageState('qrCode/urlExpanded', true));
|
||||
|
||||
// 初始值应从快照恢复,而非默认值
|
||||
expect(result.current[0]).toBe(false);
|
||||
});
|
||||
|
||||
it('应该从 Chrome Storage 异步加载并覆盖初始值', async () => {
|
||||
(storageUtil.get as any).mockImplementation((_key: string, _defaultValue: any) =>
|
||||
Promise.resolve(false),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStorageState('qrCode/urlExpanded', true));
|
||||
|
||||
// 初始值为默认值(无快照)
|
||||
expect(result.current[0]).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
// 异步加载后应覆盖为存储值
|
||||
expect(result.current[0]).toBe(false);
|
||||
expect(result.current[2]).toBe(true); // isInitialized
|
||||
});
|
||||
});
|
||||
|
||||
it('状态变化时应该保存到 Chrome Storage 和 localStorage 快照', async () => {
|
||||
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
||||
Promise.resolve(defaultValue),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStorageState('qrCode/urlExpanded', true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current[2]).toBe(true); // isInitialized
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current[1](false);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(false);
|
||||
expect(storageUtil.set).toHaveBeenCalledWith('qrCode/urlExpanded', false);
|
||||
|
||||
// 应同时写入 localStorage 快照
|
||||
expect(localStorage.getItem('snapshot/qrCode/urlExpanded')).toBe(JSON.stringify(false));
|
||||
});
|
||||
|
||||
it('应该支持验证器 - 合法值通过', async () => {
|
||||
const validator = (val: unknown): val is JsonToolsPageMode =>
|
||||
typeof val === 'string' &&
|
||||
(['diff', 'format', 'yaml', 'toml', 'minify'] as string[]).includes(val);
|
||||
|
||||
(storageUtil.get as any).mockImplementation(() => Promise.resolve('yaml'));
|
||||
|
||||
const { result } = renderHook(() => useStorageState('jsonTools/pageMode', 'diff', validator));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current[0]).toBe('yaml');
|
||||
});
|
||||
});
|
||||
|
||||
it('应该支持验证器 - 非法值回退到默认值', async () => {
|
||||
const validator = (val: unknown): val is JsonToolsPageMode =>
|
||||
typeof val === 'string' &&
|
||||
(['diff', 'format', 'yaml', 'toml', 'minify'] as string[]).includes(val);
|
||||
|
||||
(storageUtil.get as any).mockImplementation(() => Promise.resolve('invalidMode'));
|
||||
|
||||
const { result } = renderHook(() => useStorageState('jsonTools/pageMode', 'diff', validator));
|
||||
|
||||
await waitFor(() => {
|
||||
// 非法值应回退到默认值
|
||||
expect(result.current[0]).toBe('diff');
|
||||
});
|
||||
});
|
||||
|
||||
it('快照中的非法值应被验证器拒绝,回退到默认值', () => {
|
||||
const validator = (val: unknown): val is JsonToolsPageMode =>
|
||||
typeof val === 'string' &&
|
||||
(['diff', 'format', 'yaml', 'toml', 'minify'] as string[]).includes(val);
|
||||
|
||||
// 在 localStorage 中存入一个非法值
|
||||
localStorage.setItem('snapshot/jsonTools/pageMode', JSON.stringify('invalidMode'));
|
||||
|
||||
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
||||
Promise.resolve(defaultValue),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStorageState('jsonTools/pageMode', 'diff', validator));
|
||||
|
||||
// 快照中的非法值应被拒绝,使用默认值
|
||||
expect(result.current[0]).toBe('diff');
|
||||
});
|
||||
|
||||
it('快照中的合法值应被验证器接受', () => {
|
||||
const validator = (val: unknown): val is JsonToolsPageMode =>
|
||||
typeof val === 'string' &&
|
||||
(['diff', 'format', 'yaml', 'toml', 'minify'] as string[]).includes(val);
|
||||
|
||||
// 在 localStorage 中存入一个合法值
|
||||
localStorage.setItem('snapshot/jsonTools/pageMode', JSON.stringify('minify'));
|
||||
|
||||
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
||||
Promise.resolve(defaultValue),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStorageState('jsonTools/pageMode', 'diff', validator));
|
||||
|
||||
// 快照中的合法值应被接受
|
||||
expect(result.current[0]).toBe('minify');
|
||||
});
|
||||
|
||||
it('快照中损坏的 JSON 应被忽略,回退到默认值', () => {
|
||||
localStorage.setItem('snapshot/qrCode/urlExpanded', 'not-valid-json{');
|
||||
|
||||
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
||||
Promise.resolve(defaultValue),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStorageState('qrCode/urlExpanded', true));
|
||||
|
||||
// 损坏的快照应被忽略
|
||||
expect(result.current[0]).toBe(true);
|
||||
});
|
||||
|
||||
it('isInitialized 在异步加载完成前应为 false', () => {
|
||||
// 让 Chrome Storage 永远不 resolve
|
||||
(storageUtil.get as any).mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const { result } = renderHook(() => useStorageState('qrCode/urlExpanded', true));
|
||||
|
||||
expect(result.current[2]).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user