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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Base64 转换器工具函数
|
||||
*/
|
||||
|
||||
import { formatBytes } from './format';
|
||||
|
||||
/** 最大文件大小限制(10 MB) */
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
/** 支持的图像 MIME 类型 */
|
||||
export const SUPPORTED_IMAGE_TYPES = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
'image/svg+xml',
|
||||
'image/x-icon',
|
||||
] as const;
|
||||
|
||||
/** 支持的图像文件扩展名 */
|
||||
export const SUPPORTED_IMAGE_EXTENSIONS = [
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.webp',
|
||||
'.gif',
|
||||
'.bmp',
|
||||
'.svg',
|
||||
'.ico',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 文本转 Base64 编码结果
|
||||
*/
|
||||
export interface TextToBase64Result {
|
||||
/** Base64 编码结果 */
|
||||
output: string;
|
||||
/** 原始字节数 */
|
||||
originalBytes: number;
|
||||
/** 编码后字节数 */
|
||||
outputBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件转 Base64 编码结果
|
||||
*/
|
||||
export interface FileToBase64Result {
|
||||
/** Base64 编码结果(含 data URI 前缀) */
|
||||
output: string;
|
||||
/** 纯 Base64 字符串(不含前缀) */
|
||||
rawBase64: string;
|
||||
/** 原始字节数 */
|
||||
originalBytes: number;
|
||||
/** 编码后字节数 */
|
||||
outputBytes: number;
|
||||
/** 文件名 */
|
||||
fileName: string;
|
||||
/** 文件 MIME 类型 */
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文本字符串编码为 Base64
|
||||
*
|
||||
* @param text 输入文本
|
||||
* @returns 编码结果
|
||||
*/
|
||||
export function textToBase64(text: string): TextToBase64Result {
|
||||
const encoded = btoa(unescape(encodeURIComponent(text)));
|
||||
const originalBytes = new TextEncoder().encode(text).length;
|
||||
const outputBytes = new TextEncoder().encode(encoded).length;
|
||||
return {
|
||||
output: encoded,
|
||||
originalBytes,
|
||||
outputBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/** 匹配 data URI 的 base64 前缀,如 "data:image/png;base64,"(允许中间含参数) */
|
||||
const DATA_URI_BASE64_PREFIX = /^data:[^,]+;base64,/i;
|
||||
|
||||
/**
|
||||
* 将 Base64 字符串解码为文本
|
||||
*
|
||||
* 支持 data:<mime>;base64,<payload> 形式:会自动剥离前缀后再解码。
|
||||
* 若解码出的字节不是合法 UTF-8(典型如图片等二进制数据),抛出更易懂的错误。
|
||||
*
|
||||
* @param base64 Base64 编码字符串
|
||||
* @returns 解码后的文本
|
||||
* @throws {Error} 输入不是合法的 Base64 字符串
|
||||
* @throws {Error} 输入解码后是二进制数据,无法作为文本展示
|
||||
*/
|
||||
export function base64ToText(base64: string): string {
|
||||
const trimmed = base64.trim();
|
||||
const cleaned = trimmed.replace(DATA_URI_BASE64_PREFIX, '');
|
||||
if (!isValidBase64(cleaned)) {
|
||||
throw new Error('Invalid Base64 string');
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(escape(atob(cleaned)));
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Input appears to be binary data (e.g. an image). Please use the Image tab instead.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验字符串是否为合法的 Base64 编码
|
||||
*
|
||||
* @param str 待校验字符串
|
||||
* @returns 是否合法
|
||||
*/
|
||||
export function isValidBase64(str: string): boolean {
|
||||
if (!str || str.length === 0) return false;
|
||||
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
if (!base64Regex.test(str)) return false;
|
||||
if (str.length % 4 !== 0) return false;
|
||||
try {
|
||||
atob(str);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验文件大小是否在限制范围内
|
||||
*
|
||||
* @param fileSize 文件大小(字节)
|
||||
* @returns 是否合法
|
||||
*/
|
||||
export function isFileSizeValid(fileSize: number): boolean {
|
||||
return fileSize > 0 && fileSize <= MAX_FILE_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 MIME 类型是否为支持的图像类型
|
||||
*
|
||||
* @param mimeType MIME 类型
|
||||
* @returns 是否为支持的图像类型
|
||||
*/
|
||||
export function isSupportedImageType(mimeType: string): boolean {
|
||||
return (SUPPORTED_IMAGE_TYPES as readonly string[]).includes(mimeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验文件扩展名是否为支持的图像格式
|
||||
*
|
||||
* @param fileName 文件名
|
||||
* @returns 是否为支持的图像格式
|
||||
*/
|
||||
export function isSupportedImageExtension(fileName: string): boolean {
|
||||
const lowerName = fileName.toLowerCase();
|
||||
return (SUPPORTED_IMAGE_EXTENSIONS as readonly string[]).some((ext) => lowerName.endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文件转换为 Base64 编码(使用 FileReader 异步读取)
|
||||
*
|
||||
* @param file 文件对象
|
||||
* @returns Promise<FileToBase64Result> 编码结果
|
||||
* @throws {Error} 如果文件为空或超出大小限制
|
||||
*/
|
||||
export function fileToBase64(file: File): Promise<FileToBase64Result> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!file) {
|
||||
reject(new Error('No file provided'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFileSizeValid(file.size)) {
|
||||
reject(new Error(`File size exceeds the limit (${MAX_FILE_SIZE / 1024 / 1024} MB)`));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
const dataUri = reader.result as string;
|
||||
const commaIndex = dataUri.indexOf(',');
|
||||
const rawBase64 = commaIndex >= 0 ? dataUri.substring(commaIndex + 1) : dataUri;
|
||||
|
||||
resolve({
|
||||
output: dataUri,
|
||||
rawBase64,
|
||||
originalBytes: file.size,
|
||||
outputBytes: rawBase64.length,
|
||||
fileName: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
});
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error('Failed to read file'));
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 data URI 中提取 MIME 类型
|
||||
*
|
||||
* @param dataUri data URI 字符串
|
||||
* @returns MIME 类型
|
||||
*/
|
||||
export function extractMimeTypeFromDataUri(dataUri: string): string {
|
||||
const match = dataUri.match(/^data:([^;,]+)[^,]*;base64,/);
|
||||
return match ? match[1] : 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小显示(兼容旧接口,内部委托给 formatBytes)
|
||||
*
|
||||
* @param bytes 字节数
|
||||
* @returns 格式化后的字符串
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
return formatBytes(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 解码为二进制后的产物
|
||||
*/
|
||||
export interface Base64ToBlobResult {
|
||||
/** 解码后的 Blob */
|
||||
blob: Blob;
|
||||
/** 推断出的 MIME 类型 */
|
||||
mimeType: string;
|
||||
/** 推荐的扩展名,含点(如 `.png`),无法识别时为 `.bin` */
|
||||
suggestedExtension: string;
|
||||
/** 已去除 data URI 前缀的纯 Base64 字符串 */
|
||||
rawBase64: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已知文件类型魔数签名表。注意:ZIP 头同样会匹配 .docx/.xlsx/.pptx/.apk
|
||||
*
|
||||
* 签名匹配规则:
|
||||
* - `bytes` 必须匹配文件起始
|
||||
* - 可选的 `tail` 用于多段签名(如 WebP:"RIFF" + 偏移 8 处的 "WEBP")
|
||||
*/
|
||||
const MAGIC_BYTE_SIGNATURES: ReadonlyArray<{
|
||||
bytes: readonly number[];
|
||||
tail?: { offset: number; bytes: readonly number[] };
|
||||
mime: string;
|
||||
ext: string;
|
||||
}> = [
|
||||
{ bytes: [0x89, 0x50, 0x4e, 0x47], mime: 'image/png', ext: '.png' },
|
||||
{ bytes: [0xff, 0xd8, 0xff], mime: 'image/jpeg', ext: '.jpg' },
|
||||
{ bytes: [0x47, 0x49, 0x46, 0x38], mime: 'image/gif', ext: '.gif' },
|
||||
{ bytes: [0x42, 0x4d], mime: 'image/bmp', ext: '.bmp' },
|
||||
{
|
||||
bytes: [0x52, 0x49, 0x46, 0x46],
|
||||
tail: { offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },
|
||||
mime: 'image/webp',
|
||||
ext: '.webp',
|
||||
},
|
||||
{ bytes: [0x25, 0x50, 0x44, 0x46], mime: 'application/pdf', ext: '.pdf' },
|
||||
{ bytes: [0x50, 0x4b, 0x03, 0x04], mime: 'application/zip', ext: '.zip' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 将 Base64 字符串解码为 Uint8Array
|
||||
*
|
||||
* @param b64 纯 Base64 字符串(不含 data URI 前缀)
|
||||
* @returns 字节序列
|
||||
*/
|
||||
export function base64ToBytes(b64: string): Uint8Array {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据字节序列前缀识别已知文件类型
|
||||
*
|
||||
* @param bytes 解码后的字节序列
|
||||
* @returns 匹配到的 MIME + 扩展名;未匹配返回 null
|
||||
*/
|
||||
export function sniffMimeFromBytes(bytes: Uint8Array): { mime: string; ext: string } | null {
|
||||
for (const sig of MAGIC_BYTE_SIGNATURES) {
|
||||
if (bytes.length < sig.bytes.length) continue;
|
||||
let matched = true;
|
||||
for (let i = 0; i < sig.bytes.length; i += 1) {
|
||||
if (bytes[i] !== sig.bytes[i]) {
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) continue;
|
||||
if (sig.tail) {
|
||||
const { offset, bytes: tailBytes } = sig.tail;
|
||||
if (bytes.length < offset + tailBytes.length) continue;
|
||||
let tailMatched = true;
|
||||
for (let i = 0; i < tailBytes.length; i += 1) {
|
||||
if (bytes[offset + i] !== tailBytes[i]) {
|
||||
tailMatched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!tailMatched) continue;
|
||||
}
|
||||
return { mime: sig.mime, ext: sig.ext };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Base64 / data URI 字符串解码为 Blob,自动推断 MIME 与扩展名
|
||||
*
|
||||
* MIME 推断优先级:data URI 前缀 → 字节魔数 → `application/octet-stream`
|
||||
*
|
||||
* @param input Base64 字符串或 data URI
|
||||
* @returns 解码结果
|
||||
* @throws {Error} 输入不是合法 Base64
|
||||
*/
|
||||
export function base64ToBlob(input: string): Base64ToBlobResult {
|
||||
const trimmed = input.trim();
|
||||
const prefixMatch = trimmed.match(DATA_URI_BASE64_PREFIX);
|
||||
const cleaned = prefixMatch ? trimmed.slice(prefixMatch[0].length) : trimmed;
|
||||
|
||||
if (!isValidBase64(cleaned)) {
|
||||
throw new Error('Invalid Base64 string');
|
||||
}
|
||||
|
||||
const bytes = base64ToBytes(cleaned);
|
||||
|
||||
let mimeType: string;
|
||||
let suggestedExtension: string;
|
||||
if (prefixMatch) {
|
||||
mimeType = extractMimeTypeFromDataUri(trimmed);
|
||||
const sniffed = sniffMimeFromBytes(bytes);
|
||||
suggestedExtension = sniffed?.ext ?? mimeTypeToExtension(mimeType);
|
||||
} else {
|
||||
const sniffed = sniffMimeFromBytes(bytes);
|
||||
mimeType = sniffed?.mime ?? 'application/octet-stream';
|
||||
suggestedExtension = sniffed?.ext ?? '.bin';
|
||||
}
|
||||
|
||||
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: mimeType });
|
||||
return { blob, mimeType, suggestedExtension, rawBase64: cleaned };
|
||||
}
|
||||
|
||||
/** 极小的 MIME -> 扩展名映射,仅用于带 data URI 前缀但魔数无法识别时 */
|
||||
function mimeTypeToExtension(mime: string): string {
|
||||
if (mime.startsWith('image/svg')) return '.svg';
|
||||
if (mime === 'image/webp') return '.webp';
|
||||
if (mime === 'image/bmp') return '.bmp';
|
||||
if (mime === 'image/x-icon' || mime === 'image/vnd.microsoft.icon') return '.ico';
|
||||
if (mime === 'text/plain') return '.txt';
|
||||
if (mime === 'application/json') return '.json';
|
||||
if (mime === 'text/html') return '.html';
|
||||
if (mime === 'text/css') return '.css';
|
||||
return '.bin';
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发浏览器下载指定 Blob
|
||||
*
|
||||
* @param blob 要下载的 Blob
|
||||
* @param filename 下载文件名
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* chrome.i18n 类型安全 wrapper
|
||||
* 提供与 react-i18next 兼容的接口
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取翻译文本
|
||||
* @param msgId 翻译 key(如 'timestamp_pageTitle')
|
||||
* @param substitutions 占位符替换值(可选)
|
||||
* @returns 翻译后的文本
|
||||
*/
|
||||
export function getMessage(msgId: string, substitutions?: string[]): string {
|
||||
try {
|
||||
return chrome.i18n.getMessage(msgId, substitutions);
|
||||
} catch (error) {
|
||||
console.warn(`[chrome.i18n] 无法获取翻译: ${msgId}`, error);
|
||||
return msgId; // 回退到 key 本身
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* react-i18next 兼容的 Hook
|
||||
* 返回 t 函数和相关信息
|
||||
*/
|
||||
export function useI18n(namespace?: string | string[]) {
|
||||
const namespaces = Array.isArray(namespace) ? namespace : namespace ? [namespace] : [];
|
||||
|
||||
const t = (key: string, options?: Record<string, unknown>): string => {
|
||||
let msgId = key;
|
||||
|
||||
// 处理 namespace:key 格式(兼容原 i18next 用法)
|
||||
if (key.includes(':')) {
|
||||
msgId = key.replace(':', '_').replace(/\./g, '_');
|
||||
}
|
||||
|
||||
// 先尝试直接查找 key
|
||||
let message = getMessage(msgId);
|
||||
|
||||
// 如果直接查找未命中,尝试命名空间前缀(使用转换后的 msgId)
|
||||
if (message === msgId && namespaces.length > 0) {
|
||||
for (const ns of namespaces) {
|
||||
const candidate = `${ns}_${msgId}`;
|
||||
const result = getMessage(candidate);
|
||||
if (result !== candidate) {
|
||||
message = result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理插值
|
||||
if (options) {
|
||||
for (const [placeholder, value] of Object.entries(options)) {
|
||||
message = message.replace(`{{${placeholder}}}`, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
return {
|
||||
t,
|
||||
i18n: {
|
||||
language: 'zh',
|
||||
changeLanguage: (_lng?: string) => {
|
||||
// chrome.i18n 无法动态切换语言,需要刷新页面
|
||||
console.warn('[chrome.i18n] 无法动态切换语言,需要刷新页面');
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
isLoaded: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载命名空间(无操作,兼容 useLazyTranslation)
|
||||
*/
|
||||
export async function preloadNamespaces(_namespaces: string[]): Promise<void> {
|
||||
// chrome.i18n 是同步的,无需预加载
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { StorageSchema } from '@/types/storage';
|
||||
|
||||
class StorageUtils {
|
||||
async get<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>;
|
||||
|
||||
async get<K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
defaultValue?: StorageSchema[K],
|
||||
): Promise<StorageSchema[K] | undefined>;
|
||||
|
||||
/**
|
||||
* 获取值
|
||||
* @param key
|
||||
* @param defaultValue
|
||||
* @returns
|
||||
*/
|
||||
async get<K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
defaultValue?: StorageSchema[K],
|
||||
): Promise<StorageSchema[K] | undefined> {
|
||||
const result = await chrome.storage.local.get([key]);
|
||||
return (result[key] ?? defaultValue) as StorageSchema[K] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置值
|
||||
* @param key
|
||||
* @param value
|
||||
*/
|
||||
async set<K extends keyof StorageSchema>(key: K, value: StorageSchema[K]): Promise<void> {
|
||||
await chrome.storage.local.set({ [key]: value });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除值
|
||||
* @param key
|
||||
*/
|
||||
async remove(key: keyof StorageSchema): Promise<void> {
|
||||
await chrome.storage.local.remove([key]);
|
||||
}
|
||||
}
|
||||
|
||||
export const storageUtil = new StorageUtils();
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Chrome 标签页相关工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取当前活动的标签页
|
||||
*/
|
||||
export async function getActiveTab(): Promise<chrome.tabs.Tab | null> {
|
||||
try {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
return tab || null;
|
||||
} catch (error) {
|
||||
console.error('获取活动标签页失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前活动的标签页域名
|
||||
*/
|
||||
export async function getActiveTabDomain(): Promise<string> {
|
||||
const tab = await getActiveTab();
|
||||
if (tab?.url) {
|
||||
try {
|
||||
const url = new URL(tab.url);
|
||||
return url.hostname;
|
||||
} catch (e) {
|
||||
console.error('解析域名失败:', e);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 在新标签页中打开扩展页面
|
||||
* @param page - 扩展页面路径(如 'popup.html')
|
||||
* @param params - 可选的查询参数
|
||||
*/
|
||||
export async function openExtensionPage(
|
||||
page: string,
|
||||
params?: Record<string, string>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const url = new URL(chrome.runtime.getURL(page));
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
|
||||
}
|
||||
await chrome.tabs.create({ url: url.toString() });
|
||||
} catch (error) {
|
||||
console.error('打开扩展页面失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保内容脚本已注入
|
||||
*/
|
||||
export async function ensureContentScriptInjected(): Promise<boolean> {
|
||||
try {
|
||||
const tab = await getActiveTab();
|
||||
if (!tab?.id) return false;
|
||||
|
||||
// 尝试发送一个简单的探测消息
|
||||
try {
|
||||
// 这里可以根据实际情况发送一个简单的 Ping 消息
|
||||
// 目前暂时保留原有注入逻辑,由调用方决定
|
||||
return true;
|
||||
} catch (e) {
|
||||
// 如果报错,说明没注入,执行注入
|
||||
console.log('内容脚本未注入,尝试注入...');
|
||||
console.error('注入内容脚本失败:', e);
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
files: ['/content-scripts/content.js'],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('注入内容脚本失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 复制文本到剪贴板
|
||||
* @param text 要复制的文本
|
||||
* @returns Promise<boolean> 是否复制成功
|
||||
*/
|
||||
export async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制图片到剪贴板
|
||||
* @param blob 要复制的图片
|
||||
* @returns Promise<boolean> 是否复制成功
|
||||
*/
|
||||
export async function copyImageToClipboard(blob: Blob): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
'image/png': blob,
|
||||
}),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { PageType } from '@/types/storage';
|
||||
|
||||
export interface ContextMenuItemConfig {
|
||||
id: string;
|
||||
title: string;
|
||||
contexts: [`${chrome.contextMenus.ContextType}`, ...`${chrome.contextMenus.ContextType}`[]];
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
export interface ContextMenuClickedInfo {
|
||||
featureKey: PageType;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
success: boolean;
|
||||
data?: ContextMenuClickedInfo;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const PARENT_MENU_ID = 'testing-tools-parent';
|
||||
|
||||
export const MAX_PAYLOAD_LENGTH = 10000;
|
||||
|
||||
/** 菜单项 ID 到 PageType 的映射(仅处理非常规映射) */
|
||||
const MENU_ID_TO_PAGE_TYPE: Record<string, PageType> = {
|
||||
'qrCode-page': 'qrCode',
|
||||
};
|
||||
|
||||
/**
|
||||
* 将菜单项 ID 转换为 PageType
|
||||
* 如果存在显式映射则使用映射,否则直接使用 menuItemId
|
||||
*/
|
||||
function getMenuPageType(menuItemId: string): PageType {
|
||||
return MENU_ID_TO_PAGE_TYPE[menuItemId] ?? (menuItemId as PageType);
|
||||
}
|
||||
|
||||
export const CONTEXT_MENU_CONFIGS: ContextMenuItemConfig[] = [
|
||||
{
|
||||
id: PARENT_MENU_ID,
|
||||
title: 'Testing Tools',
|
||||
contexts: [chrome.contextMenus.ContextType.ALL],
|
||||
},
|
||||
{
|
||||
id: 'jwt',
|
||||
title: '🔑 解析 JWT',
|
||||
contexts: [chrome.contextMenus.ContextType.SELECTION],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
{
|
||||
id: 'base64Converter',
|
||||
title: '🔄 Base64 解码',
|
||||
contexts: [chrome.contextMenus.ContextType.SELECTION],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
{
|
||||
id: 'textStatistics',
|
||||
title: '📊 统计选中文本',
|
||||
contexts: [chrome.contextMenus.ContextType.SELECTION],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
{
|
||||
id: 'timestamp',
|
||||
title: '⏰ 转换时间戳',
|
||||
contexts: [chrome.contextMenus.ContextType.SELECTION],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
{
|
||||
id: 'storageCleaner',
|
||||
title: '🧹 清理当前网站存储',
|
||||
contexts: [chrome.contextMenus.ContextType.PAGE],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
{
|
||||
id: 'qrCode-page',
|
||||
title: '🔗 网页链接转二维码',
|
||||
contexts: [chrome.contextMenus.ContextType.PAGE],
|
||||
parentId: PARENT_MENU_ID,
|
||||
},
|
||||
];
|
||||
|
||||
export function createAllContextMenus(): void {
|
||||
for (const config of CONTEXT_MENU_CONFIGS) {
|
||||
chrome.contextMenus.create({
|
||||
id: config.id,
|
||||
title: config.title,
|
||||
contexts: config.contexts,
|
||||
parentId: config.parentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function parseContextMenuClick(
|
||||
menuItemId: string,
|
||||
info: chrome.contextMenus.OnClickData,
|
||||
): ParseResult {
|
||||
const featureKey = getMenuPageType(menuItemId);
|
||||
|
||||
if (info.selectionText) {
|
||||
const text = info.selectionText;
|
||||
|
||||
if (text.length > MAX_PAYLOAD_LENGTH) {
|
||||
return {
|
||||
success: true,
|
||||
data: { featureKey, payload: text.substring(0, MAX_PAYLOAD_LENGTH) },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { featureKey, payload: text },
|
||||
};
|
||||
}
|
||||
|
||||
if (info.pageUrl) {
|
||||
return {
|
||||
success: true,
|
||||
data: { featureKey, payload: info.pageUrl },
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: '无法获取有效数据' };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default dayjs;
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 格式化字节大小为易读字符串
|
||||
*
|
||||
* @param bytes 字节数
|
||||
* @returns 格式化后的字符串,例如 "1.5 KB" 或 "100 B"
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let size = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
// KB uses 1 decimal, MB/GB/TB use 2 decimals
|
||||
const decimals = unitIndex === 0 ? 1 : 2;
|
||||
|
||||
return `${size.toFixed(decimals)} ${units[unitIndex]}`;
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* HTML 转 Markdown 转换器工具函数
|
||||
*
|
||||
* 基于 DOM 解析实现,支持常见 HTML 标签到 Markdown 的转换。
|
||||
*/
|
||||
|
||||
/**
|
||||
* HTML 转 Markdown 转换结果
|
||||
*/
|
||||
export interface HtmlToMarkdownResult {
|
||||
/** 转换后的 Markdown 字符串 */
|
||||
markdown: string;
|
||||
/** 原始 HTML 文本长度 */
|
||||
originalLength: number;
|
||||
/** 生成的 Markdown 长度 */
|
||||
markdownLength: number;
|
||||
/** 是否包含错误 */
|
||||
hasError: boolean;
|
||||
/** 错误信息(如果有) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 HTML 文本转换为 Markdown
|
||||
*
|
||||
* @param html - HTML 源文本
|
||||
* @returns HtmlToMarkdownResult 转换结果
|
||||
*/
|
||||
export function htmlToMarkdown(html: string): HtmlToMarkdownResult {
|
||||
try {
|
||||
const trimmed = html.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
markdown: '',
|
||||
originalLength: 0,
|
||||
markdownLength: 0,
|
||||
hasError: false,
|
||||
};
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(trimmed, 'text/html');
|
||||
|
||||
// 检查解析错误
|
||||
const parserError = doc.querySelector('parsererror');
|
||||
if (parserError) {
|
||||
return {
|
||||
markdown: '',
|
||||
originalLength: html.length,
|
||||
markdownLength: 0,
|
||||
hasError: true,
|
||||
error: 'HTML 解析失败:无效的 HTML 结构',
|
||||
};
|
||||
}
|
||||
|
||||
const markdown = convertNode(doc.body).trim();
|
||||
|
||||
return {
|
||||
markdown,
|
||||
originalLength: html.length,
|
||||
markdownLength: markdown.length,
|
||||
hasError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
markdown: '',
|
||||
originalLength: html.length,
|
||||
markdownLength: 0,
|
||||
hasError: true,
|
||||
error: error instanceof Error ? error.message : 'HTML 转换失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归转换 DOM 节点为 Markdown
|
||||
*/
|
||||
function convertNode(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return escapeMarkdownChars(node.textContent ?? '');
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const element = node as HTMLElement;
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const children = Array.from(element.childNodes);
|
||||
const inner = children.map(convertNode).join('');
|
||||
|
||||
switch (tagName) {
|
||||
case 'h1':
|
||||
return `\n# ${inner.trim()}\n\n`;
|
||||
case 'h2':
|
||||
return `\n## ${inner.trim()}\n\n`;
|
||||
case 'h3':
|
||||
return `\n### ${inner.trim()}\n\n`;
|
||||
case 'h4':
|
||||
return `\n#### ${inner.trim()}\n\n`;
|
||||
case 'h5':
|
||||
return `\n##### ${inner.trim()}\n\n`;
|
||||
case 'h6':
|
||||
return `\n###### ${inner.trim()}\n\n`;
|
||||
case 'p':
|
||||
return `\n${inner.trim()}\n\n`;
|
||||
case 'br':
|
||||
return '\n';
|
||||
case 'hr':
|
||||
return '\n---\n\n';
|
||||
case 'strong':
|
||||
case 'b':
|
||||
return `**${inner.trim()}**`;
|
||||
case 'em':
|
||||
case 'i':
|
||||
return `*${inner.trim()}*`;
|
||||
case 'del':
|
||||
case 's':
|
||||
case 'strike':
|
||||
return `~~${inner.trim()}~~`;
|
||||
case 'code':
|
||||
return `\`${inner.trim()}\``;
|
||||
case 'pre': {
|
||||
const code = element.querySelector('code');
|
||||
if (code) {
|
||||
const lang = code.getAttribute('class')?.replace(/^language-/, '') ?? '';
|
||||
return `\n\`\`\`${lang}\n${code.textContent?.trim() ?? ''}\n\`\`\`\n\n`;
|
||||
}
|
||||
return `\n\`\`\`\n${inner.trim()}\n\`\`\`\n\n`;
|
||||
}
|
||||
case 'a': {
|
||||
const href = element.getAttribute('href') ?? '';
|
||||
const title = element.getAttribute('title');
|
||||
const text = inner.trim();
|
||||
if (!text) return '';
|
||||
if (title) {
|
||||
return `[${text}](${href} "${title}")`;
|
||||
}
|
||||
return `[${text}](${href})`;
|
||||
}
|
||||
case 'img': {
|
||||
const src = element.getAttribute('src') ?? '';
|
||||
const alt = element.getAttribute('alt') ?? '';
|
||||
const imgTitle = element.getAttribute('title');
|
||||
if (imgTitle) {
|
||||
return ``;
|
||||
}
|
||||
return ``;
|
||||
}
|
||||
case 'blockquote':
|
||||
return `\n${inner
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => (line.trim() ? `> ${line}` : line))
|
||||
.join('\n')}\n\n`;
|
||||
case 'ul': {
|
||||
const items = children
|
||||
.filter((child) => (child as HTMLElement).tagName?.toLowerCase() === 'li')
|
||||
.map((li) => {
|
||||
const liElement = li as HTMLElement;
|
||||
const task = liElement.querySelector('input[type="checkbox"]');
|
||||
const liText = convertNode(li).trim();
|
||||
if (task) {
|
||||
const checked = (task as HTMLInputElement).checked;
|
||||
return `- [${checked ? 'x' : ' '}] ${liText.replace(/^\[?[ x]\]?\s*/, '')}`;
|
||||
}
|
||||
return `- ${liText}`;
|
||||
})
|
||||
.join('\n');
|
||||
return `\n${items}\n\n`;
|
||||
}
|
||||
case 'ol': {
|
||||
let index = 1;
|
||||
const start = element.getAttribute('start');
|
||||
if (start) {
|
||||
const parsed = parseInt(start, 10);
|
||||
if (!isNaN(parsed)) index = parsed;
|
||||
}
|
||||
const items = children
|
||||
.filter((child) => (child as HTMLElement).tagName?.toLowerCase() === 'li')
|
||||
.map((li) => {
|
||||
const liElement = li as HTMLElement;
|
||||
const task = liElement.querySelector('input[type="checkbox"]');
|
||||
const liText = convertNode(li).trim();
|
||||
if (task) {
|
||||
const checked = (task as HTMLInputElement).checked;
|
||||
return `${index++}. [${checked ? 'x' : ' '}] ${liText.replace(/^\[?[ x]\]?\s*/, '')}`;
|
||||
}
|
||||
return `${index++}. ${liText}`;
|
||||
})
|
||||
.join('\n');
|
||||
return `\n${items}\n\n`;
|
||||
}
|
||||
case 'li': {
|
||||
// li 内容由 ul/ol 处理,这里只返回内部文本
|
||||
return inner.trim();
|
||||
}
|
||||
case 'table': {
|
||||
return convertTable(element);
|
||||
}
|
||||
case 'div':
|
||||
case 'span':
|
||||
case 'section':
|
||||
case 'article':
|
||||
case 'main':
|
||||
case 'header':
|
||||
case 'footer':
|
||||
case 'aside':
|
||||
return inner;
|
||||
case 'script':
|
||||
case 'style':
|
||||
case 'noscript':
|
||||
return '';
|
||||
default:
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换表格元素为 Markdown
|
||||
*/
|
||||
function convertTable(table: HTMLElement): string {
|
||||
const rows = Array.from(table.querySelectorAll('tr'));
|
||||
if (rows.length === 0) return '';
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// 处理表头
|
||||
const headerRow = rows[0];
|
||||
const headerCells = Array.from(headerRow.querySelectorAll('th, td'));
|
||||
const headers = headerCells.map((cell) => (cell.textContent ?? '').trim());
|
||||
lines.push('| ' + headers.join(' | ') + ' |');
|
||||
|
||||
// 分隔行
|
||||
const aligns = headerCells.map((cell) => {
|
||||
const style = (cell as HTMLElement).style.textAlign;
|
||||
if (style === 'center') return ':---:';
|
||||
if (style === 'right') return '---:';
|
||||
return '---';
|
||||
});
|
||||
lines.push('| ' + aligns.join(' | ') + ' |');
|
||||
|
||||
// 数据行(从第二行开始)
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const cells = Array.from(rows[i].querySelectorAll('td, th'));
|
||||
const values = cells.map((cell) => (cell.textContent ?? '').trim());
|
||||
lines.push('| ' + values.join(' | ') + ' |');
|
||||
}
|
||||
|
||||
return '\n' + lines.join('\n') + '\n\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义 Markdown 特殊字符(在行内文本中)
|
||||
*/
|
||||
function escapeMarkdownChars(text: string): string {
|
||||
// 仅在特定上下文中需要转义,这里简单处理
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例 HTML 文本(用于占位提示)
|
||||
*/
|
||||
export const SAMPLE_HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>示例</title></head>
|
||||
<body>
|
||||
<h1>欢迎使用 HTML 转 Markdown</h1>
|
||||
<p>这是一个 <strong>HTML</strong> 到 <em>Markdown</em> 转换器。</p>
|
||||
|
||||
<h2>支持的标签</h2>
|
||||
<ul>
|
||||
<li>标题:h1-h6</li>
|
||||
<li>文本格式:strong、em、del、code</li>
|
||||
<li>链接和图像</li>
|
||||
<li>列表:ul、ol</li>
|
||||
<li>表格:table</li>
|
||||
<li>引用:blockquote</li>
|
||||
</ul>
|
||||
|
||||
<h3>代码示例</h3>
|
||||
<pre><code class="language-javascript">function hello() {
|
||||
console.log('Hello, World!');
|
||||
}</code></pre>
|
||||
|
||||
<h3>表格示例</h3>
|
||||
<table>
|
||||
<tr><th>名称</th><th>类型</th></tr>
|
||||
<tr><td>name</td><td>string</td></tr>
|
||||
<tr><td>age</td><td>number</td></tr>
|
||||
</table>
|
||||
|
||||
<blockquote>
|
||||
<p>这是一段引用文本。</p>
|
||||
</blockquote>
|
||||
|
||||
<p>开始转换你的 HTML 内容吧!</p>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
/**
|
||||
* 下载 Markdown 文件
|
||||
*
|
||||
* @param content - Markdown 内容
|
||||
* @param filename - 下载文件名
|
||||
*/
|
||||
export function downloadMarkdownFile(content: string, filename: string = 'export.md'): void {
|
||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* JSON 格式化选项
|
||||
*/
|
||||
export interface JsonFormatOptions {
|
||||
/** 缩进空格数,默认 2 */
|
||||
indentSize: number;
|
||||
/** 是否按键名字母顺序排序,默认 false */
|
||||
sortKeys: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 格式化结果
|
||||
*/
|
||||
export interface JsonFormatResult {
|
||||
/** 格式化后的 JSON 字符串 */
|
||||
formatted: string;
|
||||
/** 原始输入的字节大小 */
|
||||
originalBytes: number;
|
||||
/** 格式化后的字节大小 */
|
||||
formattedBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归对 JSON 对象的键进行字母排序
|
||||
*
|
||||
* @param obj - 要排序的对象
|
||||
* @returns 键排序后的新对象
|
||||
*/
|
||||
function sortObjectKeys(obj: unknown): unknown {
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(sortObjectKeys);
|
||||
}
|
||||
const sorted: Record<string, unknown> = {};
|
||||
const keys = Object.keys(obj as Record<string, unknown>).sort();
|
||||
for (const key of keys) {
|
||||
sorted[key] = sortObjectKeys((obj as Record<string, unknown>)[key]);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 JSON 字符串
|
||||
*
|
||||
* @param text - 输入的 JSON 字符串(可以是压缩或格式混乱的)
|
||||
* @param options - 格式化选项
|
||||
* @returns 格式化结果
|
||||
* @throws {SyntaxError} 当输入不是有效的 JSON 时抛出语法错误
|
||||
*/
|
||||
export function formatJson(text: string, options: JsonFormatOptions): JsonFormatResult {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return { formatted: '', originalBytes: 0, formattedBytes: 0 };
|
||||
}
|
||||
|
||||
let parsed: unknown = JSON.parse(trimmed);
|
||||
|
||||
if (options.sortKeys) {
|
||||
parsed = sortObjectKeys(parsed);
|
||||
}
|
||||
|
||||
const formatted = JSON.stringify(parsed, null, options.indentSize);
|
||||
const originalBytes = new TextEncoder().encode(trimmed).length;
|
||||
const formattedBytes = new TextEncoder().encode(formatted).length;
|
||||
|
||||
return { formatted, originalBytes, formattedBytes };
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 压缩结果
|
||||
*/
|
||||
export interface JsonMinifyResult {
|
||||
/** 压缩后的 JSON 字符串 */
|
||||
minified: string;
|
||||
/** 原始输入的字节大小 */
|
||||
originalBytes: number;
|
||||
/** 压缩后的字节大小 */
|
||||
minifiedBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩 JSON 字符串
|
||||
*
|
||||
* 将格式化的 JSON 字符串压缩为单行,移除所有不必要的空白字符、换行符和缩进。
|
||||
*
|
||||
* @param text - 输入的 JSON 字符串
|
||||
* @returns 压缩结果
|
||||
* @throws {SyntaxError} 当输入不是有效的 JSON 时抛出语法错误
|
||||
*/
|
||||
export function minifyJson(text: string): JsonMinifyResult {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return { minified: '', originalBytes: 0, minifiedBytes: 0 };
|
||||
}
|
||||
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
const minified = JSON.stringify(parsed);
|
||||
const originalBytes = new TextEncoder().encode(trimmed).length;
|
||||
const minifiedBytes = new TextEncoder().encode(minified).length;
|
||||
|
||||
return { minified, originalBytes, minifiedBytes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 JSON 字符串是否有效
|
||||
*
|
||||
* @param text - 输入的 JSON 字符串
|
||||
* @returns 如果有效返回 null,否则返回错误消息
|
||||
*/
|
||||
export function validateJson(text: string): string | null {
|
||||
if (!text.trim()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JSON.parse(text.trim());
|
||||
return null;
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
return e.message;
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* JSON 转 TOML 转换结果
|
||||
*/
|
||||
export interface JsonToTomlResult {
|
||||
/** 转换后的 TOML 字符串 */
|
||||
output: string;
|
||||
/** 原始输入的字节大小 */
|
||||
originalBytes: number;
|
||||
/** 转换后的字节大小 */
|
||||
outputBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字符串转为 TOML 安全的双引号字符串
|
||||
* 转义规则:双引号、反斜杠、控制字符
|
||||
*/
|
||||
function stringifyTomlString(str: string): string {
|
||||
const escaped = str
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\t/g, '\\t')
|
||||
.replace(/\r/g, '\\r');
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 TOML 键转为安全形式
|
||||
* 简单键(仅字母、数字、连字符、下划线)直接输出,否则使用引号
|
||||
*/
|
||||
function stringifyTomlKey(key: string): string {
|
||||
if (/^[A-Za-z0-9_-]+$/.test(key)) {
|
||||
return key;
|
||||
}
|
||||
return stringifyTomlString(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 基本值转换为 TOML 值字符串
|
||||
*/
|
||||
function toTomlValue(value: unknown): string {
|
||||
if (value === null) {
|
||||
// TOML 没有 null,使用空字符串表示
|
||||
return '""';
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return stringifyTomlString(value);
|
||||
}
|
||||
// 复杂类型不在此处处理
|
||||
return stringifyTomlString(String(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断值是否为 TOML 基本类型(可直接内联表示)
|
||||
*/
|
||||
function isTomlPrimitive(value: unknown): boolean {
|
||||
return (
|
||||
value === null ||
|
||||
typeof value === 'boolean' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断值是否为纯基本类型数组(所有元素都是基本类型)
|
||||
*/
|
||||
function isHomogeneousPrimitiveArray(arr: unknown[]): boolean {
|
||||
return arr.every(isTomlPrimitive);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 值转换为 TOML 格式字符串
|
||||
*
|
||||
* @param value - 已解析的 JSON 值
|
||||
* @param path - 当前 TOML 表路径(用于嵌套对象生成 [table] 头部)
|
||||
* @param lines - 输出行收集器
|
||||
*/
|
||||
function toTomlLines(value: unknown, path: string[], lines: string[]): void {
|
||||
if (value === null || isTomlPrimitive(value)) {
|
||||
// 顶层的原始值,不生成有效 TOML(TOML 要求顶层是表)
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// 顶层数组:使用 TOML 的 table array 语法 [[path]]
|
||||
for (const item of value) {
|
||||
const header = path.length > 0 ? `[[${path.join('.')}]]` : '';
|
||||
if (header) {
|
||||
if (lines.length > 0 && lines[lines.length - 1] !== '') {
|
||||
lines.push('');
|
||||
}
|
||||
lines.push(header);
|
||||
}
|
||||
if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
|
||||
processObjectEntries(item as Record<string, unknown>, path, lines);
|
||||
} else if (Array.isArray(item)) {
|
||||
// 嵌套数组
|
||||
toTomlLines(item, [...path], lines);
|
||||
} else if (isTomlPrimitive(item)) {
|
||||
// 不应该发生:数组中的原始值在顶层已处理
|
||||
lines.push(`value = ${toTomlValue(item)}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
processObjectEntries(value as Record<string, unknown>, path, lines);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理对象的所有键值对,按 TOML 规范分类:
|
||||
* 1. 基本类型键值对(直接输出 key = value)
|
||||
* 2. 基本类型数组(直接输出 key = [v1, v2, ...])
|
||||
* 3. 嵌套对象(生成 [table] 或 [[table]])
|
||||
* 4. 嵌套数组中的对象(生成 [[table]])
|
||||
*/
|
||||
function processObjectEntries(
|
||||
obj: Record<string, unknown>,
|
||||
parentPath: string[],
|
||||
lines: string[],
|
||||
): void {
|
||||
const keys = Object.keys(obj);
|
||||
|
||||
// 先输出基本类型键值对和基本类型数组
|
||||
for (const key of keys) {
|
||||
const value = obj[key];
|
||||
const tomlKey = stringifyTomlKey(key);
|
||||
|
||||
if (isTomlPrimitive(value)) {
|
||||
lines.push(`${tomlKey} = ${toTomlValue(value)}`);
|
||||
} else if (Array.isArray(value) && isHomogeneousPrimitiveArray(value)) {
|
||||
const items = value.map(toTomlValue).join(', ');
|
||||
lines.push(`${tomlKey} = [${items}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// 再处理嵌套对象和对象数组
|
||||
for (const key of keys) {
|
||||
const value = obj[key];
|
||||
const currentPath = [...parentPath, key];
|
||||
|
||||
if (isTomlPrimitive(value)) {
|
||||
// 已处理
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (isHomogeneousPrimitiveArray(value)) {
|
||||
// 已处理
|
||||
continue;
|
||||
}
|
||||
|
||||
// 对象数组或其他复杂数组
|
||||
if (lines.length > 0 && lines[lines.length - 1] !== '') {
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
for (const item of value) {
|
||||
lines.push(`[[${currentPath.join('.')}]]`);
|
||||
if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
|
||||
processObjectEntries(item as Record<string, unknown>, currentPath, lines);
|
||||
} else if (Array.isArray(item)) {
|
||||
toTomlLines(item, currentPath, lines);
|
||||
}
|
||||
}
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// 嵌套对象 -> TOML 表
|
||||
if (lines.length > 0 && lines[lines.length - 1] !== '') {
|
||||
lines.push('');
|
||||
}
|
||||
lines.push(`[${currentPath.join('.')}]`);
|
||||
processObjectEntries(value as Record<string, unknown>, currentPath, lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 字符串转换为 TOML 格式
|
||||
*
|
||||
* 注意:TOML 要求顶层必须是一个表(对象),因此如果输入是基本类型或数组,
|
||||
* 转换结果会将其包裹在虚拟键下。
|
||||
*
|
||||
* @param text - 输入的 JSON 字符串
|
||||
* @returns 转换结果
|
||||
* @throws {SyntaxError} 当输入不是有效的 JSON 时抛出语法错误
|
||||
* @throws {Error} 当顶层 JSON 值不是对象时抛出错误
|
||||
*/
|
||||
export function jsonToToml(text: string): JsonToTomlResult {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return { output: '', originalBytes: 0, outputBytes: 0 };
|
||||
}
|
||||
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
|
||||
// TOML 要求顶层是表
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('TOML requires the top-level value to be an object');
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
toTomlLines(parsed, [], lines);
|
||||
|
||||
const output = lines.join('\n').trim();
|
||||
const originalBytes = new TextEncoder().encode(trimmed).length;
|
||||
const outputBytes = new TextEncoder().encode(output).length;
|
||||
|
||||
return { output, originalBytes, outputBytes };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* JSON 转 YAML 转换结果
|
||||
*/
|
||||
export interface JsonToYamlResult {
|
||||
/** 转换后的 YAML 字符串 */
|
||||
output: string;
|
||||
/** 原始输入的字节大小 */
|
||||
originalBytes: number;
|
||||
/** 转换后的字节大小 */
|
||||
outputBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 值转换为 YAML 字符串表示
|
||||
*
|
||||
* @param value - 已解析的 JSON 值
|
||||
* @param indent - 当前缩进级别
|
||||
* @returns YAML 字符串
|
||||
*/
|
||||
function toYamlString(value: unknown, indent: number): string {
|
||||
if (value === null) {
|
||||
return 'null';
|
||||
}
|
||||
if (value === true) {
|
||||
return 'true';
|
||||
}
|
||||
if (value === false) {
|
||||
return 'false';
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return stringifyYamlString(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return arrayToYaml(value, indent);
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return objectToYaml(value as Record<string, unknown>, indent);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字符串转为 YAML 安全的表示
|
||||
* 对于包含特殊字符的字符串使用双引号包裹
|
||||
*/
|
||||
function stringifyYamlString(str: string): string {
|
||||
// 需要引号包裹的情况:空字符串、以特殊字符开头、包含特殊字符
|
||||
const needsQuoting =
|
||||
str === '' ||
|
||||
str === 'null' ||
|
||||
str === 'true' ||
|
||||
str === 'false' ||
|
||||
/[:#{}[\],&*?|>\-!%@`]/.test(str) ||
|
||||
str.includes(' ') ||
|
||||
str.includes('\n') ||
|
||||
/^\d/.test(str);
|
||||
|
||||
if (!needsQuoting) {
|
||||
return str;
|
||||
}
|
||||
|
||||
// 转义双引号和反斜杠,然后用双引号包裹
|
||||
const escaped = str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 对象转换为 YAML 映射
|
||||
*/
|
||||
function objectToYaml(obj: Record<string, unknown>, indent: number): string {
|
||||
const prefix = ' '.repeat(indent);
|
||||
const keys = Object.keys(obj);
|
||||
|
||||
if (keys.length === 0) {
|
||||
return '{}';
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const key of keys) {
|
||||
const value = obj[key];
|
||||
const safeKey = /^[a-zA-Z0-9_-]+$/.test(key) ? key : stringifyYamlString(key);
|
||||
|
||||
if (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value as Record<string, unknown>).length > 0
|
||||
) {
|
||||
// 非空嵌套对象
|
||||
lines.push(`${prefix}${safeKey}:`);
|
||||
lines.push(toYamlString(value, indent + 1));
|
||||
} else if (Array.isArray(value) && value.length > 0) {
|
||||
// 非空数组
|
||||
lines.push(`${prefix}${safeKey}:`);
|
||||
lines.push(arrayToYaml(value, indent + 1));
|
||||
} else {
|
||||
// 基本值、空对象、空数组
|
||||
lines.push(`${prefix}${safeKey}: ${toYamlString(value, indent + 1)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 数组转换为 YAML 序列
|
||||
*/
|
||||
function arrayToYaml(arr: unknown[], indent: number): string {
|
||||
const prefix = ' '.repeat(indent);
|
||||
|
||||
if (arr.length === 0) {
|
||||
return `${prefix}[]`;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const item of arr) {
|
||||
if (
|
||||
item !== null &&
|
||||
typeof item === 'object' &&
|
||||
!Array.isArray(item) &&
|
||||
Object.keys(item as Record<string, unknown>).length > 0
|
||||
) {
|
||||
// 数组中的非空对象
|
||||
const objLines = objectToYaml(item as Record<string, unknown>, indent + 1);
|
||||
lines.push(`${prefix}- ${objLines.trimStart()}`);
|
||||
} else if (Array.isArray(item) && item.length > 0) {
|
||||
// 数组中的非空数组
|
||||
lines.push(`${prefix}-`);
|
||||
lines.push(arrayToYaml(item, indent + 1));
|
||||
} else {
|
||||
// 基本值、空对象、空数组
|
||||
lines.push(`${prefix}- ${toYamlString(item, indent + 1)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 字符串转换为 YAML 格式
|
||||
*
|
||||
* @param text - 输入的 JSON 字符串
|
||||
* @returns 转换结果
|
||||
* @throws {SyntaxError} 当输入不是有效的 JSON 时抛出语法错误
|
||||
*/
|
||||
export function jsonToYaml(text: string): JsonToYamlResult {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return { output: '', originalBytes: 0, outputBytes: 0 };
|
||||
}
|
||||
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
const output = toYamlString(parsed, 0);
|
||||
const originalBytes = new TextEncoder().encode(trimmed).length;
|
||||
const outputBytes = new TextEncoder().encode(output).length;
|
||||
|
||||
return { output, originalBytes, outputBytes };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* JWT 解析工具
|
||||
*/
|
||||
|
||||
import { getMessage } from '@/utils/chromeI18n';
|
||||
|
||||
export interface JwtHeader {
|
||||
alg: string;
|
||||
typ?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
iss?: string;
|
||||
sub?: string;
|
||||
aud?: string | string[];
|
||||
exp?: number;
|
||||
nbf?: number;
|
||||
iat?: number;
|
||||
jti?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface JwtResult {
|
||||
header: JwtHeader | null;
|
||||
payload: JwtPayload | null;
|
||||
signature: string;
|
||||
raw: {
|
||||
header: string;
|
||||
payload: string;
|
||||
signature: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64URL 解码
|
||||
* @param str Base64URL 编码字符串
|
||||
*/
|
||||
export function decodeBase64Url(str: string): string {
|
||||
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
const pad = base64.length % 4;
|
||||
if (pad) {
|
||||
if (pad === 1) {
|
||||
throw new Error(getMessage('jwt_errors_invalidBase64String'));
|
||||
}
|
||||
base64 += new Array(5 - pad).join('=');
|
||||
}
|
||||
|
||||
try {
|
||||
const binStr = atob(base64);
|
||||
const binLen = binStr.length;
|
||||
const bytes = new Uint8Array(binLen);
|
||||
for (let i = 0; i < binLen; i++) {
|
||||
bytes[i] = binStr.charCodeAt(i);
|
||||
}
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
return decoder.decode(bytes);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
getMessage('jwt_errors_failedToDecode') + (e instanceof Error ? e.message : String(e)),
|
||||
{ cause: e },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JWT 字符串
|
||||
* @param token JWT 字符串
|
||||
*/
|
||||
export function parseJwt(token: string): JwtResult {
|
||||
const parts = token.trim().split('.');
|
||||
|
||||
if (parts.length !== 3) {
|
||||
return {
|
||||
header: null,
|
||||
payload: null,
|
||||
signature: '',
|
||||
raw: { header: '', payload: '', signature: '' },
|
||||
error: getMessage('jwt_errors_invalidFormat'),
|
||||
};
|
||||
}
|
||||
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
const result: JwtResult = {
|
||||
header: null,
|
||||
payload: null,
|
||||
signature: signatureB64,
|
||||
raw: {
|
||||
header: headerB64,
|
||||
payload: payloadB64,
|
||||
signature: signatureB64,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const headerJson = decodeBase64Url(headerB64);
|
||||
result.header = JSON.parse(headerJson);
|
||||
} catch (e) {
|
||||
result.error =
|
||||
getMessage('jwt_errors_parseHeaderFailed') + (e instanceof Error ? e.message : String(e));
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const payloadJson = decodeBase64Url(payloadB64);
|
||||
result.payload = JSON.parse(payloadJson);
|
||||
} catch (e) {
|
||||
result.error =
|
||||
getMessage('jwt_errors_parsePayloadFailed') + (e instanceof Error ? e.message : String(e));
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将对象格式化为 JSON 字符串
|
||||
* @param obj 对象
|
||||
*/
|
||||
export function stringifyJson(obj: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(obj, null, 2);
|
||||
} catch (e) {
|
||||
console.error('格式化 JSON 失败:', e);
|
||||
return String(obj);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { marked, type MarkedOptions } from 'marked';
|
||||
|
||||
/**
|
||||
* Markdown 转 HTML 转换结果
|
||||
*/
|
||||
export interface MarkdownToHtmlResult {
|
||||
/** 转换后的 HTML 字符串 */
|
||||
html: string;
|
||||
/** 原始 Markdown 文本长度 */
|
||||
originalLength: number;
|
||||
/** 生成的 HTML 长度 */
|
||||
htmlLength: number;
|
||||
/** 是否包含错误 */
|
||||
hasError: boolean;
|
||||
/** 错误信息(如果有) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认的 Markdown 渲染选项配置
|
||||
*/
|
||||
const defaultMarkedOptions: MarkedOptions = {
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 将 Markdown 文本转换为 HTML
|
||||
*
|
||||
* @param markdown - Markdown 源文本
|
||||
* @returns MarkdownToHtmlResult 转换结果
|
||||
*/
|
||||
export function markdownToHtml(markdown: string): MarkdownToHtmlResult {
|
||||
try {
|
||||
const trimmed = markdown.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
html: '',
|
||||
originalLength: 0,
|
||||
htmlLength: 0,
|
||||
hasError: false,
|
||||
};
|
||||
}
|
||||
|
||||
const html = marked.parse(trimmed, { ...defaultMarkedOptions, async: false });
|
||||
|
||||
return {
|
||||
html: html.trim(),
|
||||
originalLength: markdown.length,
|
||||
htmlLength: html.length,
|
||||
hasError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
html: '',
|
||||
originalLength: markdown.length,
|
||||
htmlLength: 0,
|
||||
hasError: true,
|
||||
error: error instanceof Error ? error.message : 'Markdown 解析失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 HTML 内容包装完整的文档结构(用于打印/下载)
|
||||
*
|
||||
* @param html - 主体 HTML 内容
|
||||
* @param title - 文档标题
|
||||
* @returns 完整的 HTML 文档字符串
|
||||
*/
|
||||
export function wrapHtmlDocument(html: string, title: string = 'Markdown Export'): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
h1 { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
|
||||
h2 { font-size: 1.5em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
|
||||
h3 { font-size: 1.25em; }
|
||||
p { margin-top: 0; margin-bottom: 16px; }
|
||||
a { color: #0366d6; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
code {
|
||||
background-color: rgba(27, 31, 35, 0.05);
|
||||
border-radius: 3px;
|
||||
font-size: 85%;
|
||||
margin: 0;
|
||||
padding: 0.2em 0.4em;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
}
|
||||
pre {
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
pre code {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
display: inline;
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
word-wrap: normal;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 0.25em solid #dfe2e5;
|
||||
color: #6a737d;
|
||||
margin: 0;
|
||||
padding: 0 1em;
|
||||
}
|
||||
ul, ol { margin-top: 0; margin-bottom: 16px; padding-left: 2em; }
|
||||
li + li { margin-top: 0.25em; }
|
||||
img { max-width: 100%; box-sizing: content-box; }
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
display: block;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
}
|
||||
table th, table td {
|
||||
border: 1px solid #dfe2e5;
|
||||
padding: 6px 13px;
|
||||
}
|
||||
table tr:nth-child(2n) { background-color: #f6f8fa; }
|
||||
table th { font-weight: 600; background-color: #f6f8fa; }
|
||||
hr {
|
||||
background-color: #e1e4e8;
|
||||
border: 0;
|
||||
height: 0.25em;
|
||||
margin: 24px 0;
|
||||
padding: 0;
|
||||
}
|
||||
input[type="checkbox"] { margin-right: 0.5em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${html}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义 HTML 特殊字符
|
||||
*/
|
||||
function escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 HTML 文件
|
||||
*
|
||||
* @param content - 文件内容
|
||||
* @param filename - 下载文件名
|
||||
*/
|
||||
export function downloadHtmlFile(content: string, filename: string = 'export.html'): void {
|
||||
const blob = new Blob([content], { type: 'text/html;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印 HTML 内容
|
||||
*
|
||||
* @param html - 要打印的 HTML 内容
|
||||
* @param title - 打印窗口标题
|
||||
*/
|
||||
export function printHtml(html: string, title: string = 'Markdown Preview'): void {
|
||||
const doc = wrapHtmlDocument(html, title);
|
||||
const blob = new Blob([doc], { type: 'text/html;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const printWindow = window.open(url, '_blank', 'width=800,height=600');
|
||||
if (!printWindow) {
|
||||
URL.revokeObjectURL(url);
|
||||
console.error('无法打开打印窗口,请检查浏览器弹窗拦截设置');
|
||||
return;
|
||||
}
|
||||
|
||||
// 等待样式加载完成后打印
|
||||
let printed = false;
|
||||
printWindow.onload = () => {
|
||||
if (!printed) {
|
||||
printed = true;
|
||||
printWindow.print();
|
||||
}
|
||||
};
|
||||
// 部分浏览器 onload 不触发,使用延迟回退
|
||||
setTimeout(() => {
|
||||
if (!printed) {
|
||||
printed = true;
|
||||
printWindow.print();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// 打印完成后释放 Blob URL(浏览器标签页关闭后也会自动回收)
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例 Markdown 文本(用于占位提示)
|
||||
*/
|
||||
export const SAMPLE_MARKDOWN = `# 欢迎使用 Markdown 转 HTML
|
||||
|
||||
这是一个 **Markdown** 编辑器,支持实时预览。
|
||||
|
||||
## 基础语法
|
||||
|
||||
### 标题
|
||||
使用 \`#\` 符号表示不同级别的标题。
|
||||
|
||||
### 列表
|
||||
- 无序列表项 1
|
||||
- 无序列表项 2
|
||||
- 嵌套列表项
|
||||
|
||||
1. 有序列表项 1
|
||||
2. 有序列表项 2
|
||||
|
||||
### 文本样式
|
||||
- **粗体文本**
|
||||
- *斜体文本*
|
||||
- ~~删除线文本~~
|
||||
- \`行内代码\`
|
||||
|
||||
### 链接与图片
|
||||
[访问 GitHub](https://github.com)
|
||||
|
||||
### 代码块
|
||||
\`\`\`javascript
|
||||
function hello() {
|
||||
console.log('Hello, World!');
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 扩展语法
|
||||
|
||||
### 表格
|
||||
| 名称 | 类型 | 描述 |
|
||||
|------|------|------|
|
||||
| name | string | 用户名 |
|
||||
| age | number | 年龄 |
|
||||
|
||||
### 任务列表
|
||||
- [x] 已完成任务
|
||||
- [ ] 待办任务
|
||||
|
||||
### 引用
|
||||
> 这是一段引用文本。
|
||||
> 可以有多行。
|
||||
|
||||
### 分割线
|
||||
|
||||
---
|
||||
|
||||
*开始编辑你的 Markdown 内容吧!*`;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { defineExtensionMessaging } from '@webext-core/messaging';
|
||||
|
||||
export enum MessageAction {
|
||||
RELOAD_TAB = 'reloadTab',
|
||||
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
|
||||
CONTEXT_MENU_CLICKED = 'contextMenuClicked',
|
||||
RESTORE_RIGHT_CLICK = 'restoreRightClick',
|
||||
QUERY_RIGHT_CLICK_STATUS = 'queryRightClickStatus',
|
||||
INJECT_MAIN_WORLD_SCRIPT = 'injectMainWorldScript',
|
||||
}
|
||||
|
||||
export interface MessageResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ContextMenuClickedPayload {
|
||||
featureKey: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface ProtocolMap {
|
||||
[MessageAction.RELOAD_TAB](data: { tabId: number; delay?: number }): MessageResponse;
|
||||
[MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): void;
|
||||
[MessageAction.CONTEXT_MENU_CLICKED](data: ContextMenuClickedPayload): void;
|
||||
[MessageAction.RESTORE_RIGHT_CLICK](data: undefined): MessageResponse & { restored: boolean };
|
||||
[MessageAction.QUERY_RIGHT_CLICK_STATUS](
|
||||
data: undefined,
|
||||
): MessageResponse & { restored: boolean };
|
||||
[MessageAction.INJECT_MAIN_WORLD_SCRIPT](data: undefined): MessageResponse;
|
||||
}
|
||||
|
||||
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
|
||||
|
||||
export async function sendMessageToContent<K extends keyof ProtocolMap>(
|
||||
action: K,
|
||||
...args: Parameters<ProtocolMap[K]>[0] extends undefined
|
||||
? []
|
||||
: [data: Parameters<ProtocolMap[K]>[0]]
|
||||
): Promise<ReturnType<ProtocolMap[K]>> {
|
||||
try {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab?.id) {
|
||||
console.warn(`[Messaging] 无法获取当前标签页,无法发送动作: ${action}`);
|
||||
return { success: false, message: '无法获取当前标签页' } as ReturnType<ProtocolMap[K]>;
|
||||
}
|
||||
|
||||
const data = args.length > 0 ? args[0] : undefined;
|
||||
|
||||
const response = await (
|
||||
sendMessage as (
|
||||
type: K,
|
||||
data: Parameters<ProtocolMap[K]>[0],
|
||||
arg?: number,
|
||||
) => Promise<ReturnType<ProtocolMap[K]>>
|
||||
)(action, data as Parameters<ProtocolMap[K]>[0], tab.id);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[Messaging] 向内容脚本发送消息失败 [Action: ${action}]:`, errorMsg);
|
||||
|
||||
if (errorMsg.includes('Could not establish connection')) {
|
||||
return { success: false, message: '无法连接到网页,请刷新页面后再试' } as ReturnType<
|
||||
ProtocolMap[K]
|
||||
>;
|
||||
}
|
||||
if (errorMsg.includes('No response')) {
|
||||
return { success: false, message: '网页响应超时,请重试' } as ReturnType<ProtocolMap[K]>;
|
||||
}
|
||||
|
||||
return { success: false, message: `通信失败: ${errorMsg}` } as ReturnType<ProtocolMap[K]>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import QrScanner from 'qr-scanner';
|
||||
|
||||
export interface QrCodeParseResult {
|
||||
success: boolean;
|
||||
data?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件中解析二维码
|
||||
* 使用 qr-scanner 替代 jsqr 以减小体积并提高性能
|
||||
*/
|
||||
export async function parseQrCodeFromFile(file: File): Promise<QrCodeParseResult> {
|
||||
try {
|
||||
// qr-scanner 的 scanImage 方法支持直接传入 File 对象
|
||||
// 它会自动处理图片加载、Canvas 绘制和解析过程
|
||||
// 并且在支持的浏览器中会优先使用原生的 BarcodeDetector API
|
||||
const result = await QrScanner.scanImage(file, {
|
||||
returnDetailedScanResult: true,
|
||||
});
|
||||
|
||||
if (result && result.data) {
|
||||
return { success: true, data: result.data };
|
||||
} else {
|
||||
return { success: false, error: '未检测到二维码' };
|
||||
}
|
||||
} catch (err) {
|
||||
// qr-scanner 在未发现二维码时会抛出 "No QR code found"
|
||||
const errorMsg =
|
||||
err === 'No QR code found'
|
||||
? '未检测到二维码'
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import type { CleaningResult, StorageCleanerOptions, StorageCleanResult } from '@/types/storage';
|
||||
import { formatBytes } from './format';
|
||||
|
||||
const RESTRICTED_PROTOCOLS = [
|
||||
'chrome:',
|
||||
'chrome-extension:',
|
||||
'about:',
|
||||
'edge:',
|
||||
'view-source:',
|
||||
'file:',
|
||||
'data:',
|
||||
] as const;
|
||||
|
||||
export async function getCurrentTab() {
|
||||
// For popup pages, we need to get the active tab from the browser window that triggered the popup.
|
||||
// We should ONLY care about the currently active tab in the last focused window.
|
||||
// If it's a restricted URL, we return it anyway and let the caller handle the error display.
|
||||
|
||||
const [tab] = await chrome.tabs.query({
|
||||
active: true,
|
||||
lastFocusedWindow: true,
|
||||
});
|
||||
|
||||
if (tab) {
|
||||
return tab;
|
||||
}
|
||||
|
||||
// Fallback for cases where lastFocusedWindow might not work as expected (e.g. certain sidepanel scenarios)
|
||||
const [fallbackTab] = await chrome.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
});
|
||||
|
||||
return fallbackTab;
|
||||
}
|
||||
|
||||
export function isRestrictedUrl(url?: string): boolean {
|
||||
if (!url) return true;
|
||||
return RESTRICTED_PROTOCOLS.some((p) => url.startsWith(p));
|
||||
}
|
||||
|
||||
export async function getCookieSize(url: string): Promise<number> {
|
||||
try {
|
||||
const cookies = await chrome.cookies.getAll({ url });
|
||||
const encoder = new TextEncoder();
|
||||
// 估算:名称 + 值 + 域名 + 路径 的 UTF-8 字节数
|
||||
return cookies.reduce(
|
||||
(acc, c) =>
|
||||
acc +
|
||||
encoder.encode(c.name).length +
|
||||
encoder.encode(c.value).length +
|
||||
encoder.encode(c.domain ?? '').length +
|
||||
encoder.encode(c.path ?? '').length,
|
||||
0,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to get cookie size:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLocalStorageSize(tabId: number): Promise<number> {
|
||||
try {
|
||||
const [result] = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: () => {
|
||||
try {
|
||||
const encoder = new TextEncoder();
|
||||
return Object.entries(localStorage).reduce(
|
||||
(acc, [k, v]) => acc + encoder.encode(k).length + encoder.encode(v).length,
|
||||
0,
|
||||
);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
});
|
||||
return (result?.result as number) || 0;
|
||||
} catch (error) {
|
||||
console.error('Failed to get LocalStorage size:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSessionStorageSize(tabId: number): Promise<number> {
|
||||
try {
|
||||
const [result] = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: () => {
|
||||
try {
|
||||
const encoder = new TextEncoder();
|
||||
return Object.entries(sessionStorage).reduce(
|
||||
(acc, [k, v]) => acc + encoder.encode(k).length + encoder.encode(v).length,
|
||||
0,
|
||||
);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
});
|
||||
return (result?.result as number) || 0;
|
||||
} catch (error) {
|
||||
console.error('Failed to get SessionStorage size:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOriginStorageEstimate(tabId: number): Promise<number> {
|
||||
try {
|
||||
const [result] = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: async () => {
|
||||
try {
|
||||
// 注意:navigator.storage.estimate() 返回的是整个 Origin 的估算值
|
||||
// 包含 IndexedDB, CacheStorage, ServiceWorker 注册等
|
||||
if (navigator.storage && navigator.storage.estimate) {
|
||||
const estimate = await navigator.storage.estimate();
|
||||
return estimate.usage || 0;
|
||||
}
|
||||
return 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
});
|
||||
return (result?.result as number) || 0;
|
||||
} catch (error) {
|
||||
console.error('Failed to get origin storage estimate:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCacheStorageSize(tabId: number): Promise<number> {
|
||||
try {
|
||||
const [result] = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: async () => {
|
||||
try {
|
||||
if ('caches' in window) {
|
||||
const keys = await caches.keys();
|
||||
return keys.length; // 对于 CacheStorage,我们先返回缓存库的数量
|
||||
}
|
||||
return 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
});
|
||||
// 由于获取具体字节数较慢,这里返回的是缓存条目的数量标识,UI 上可以特殊处理
|
||||
return (result?.result as number) || 0;
|
||||
} catch (error) {
|
||||
console.error('Failed to get CacheStorage size:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServiceWorkerCount(tabId: number): Promise<number> {
|
||||
try {
|
||||
const [result] = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: async () => {
|
||||
try {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations();
|
||||
return regs.length;
|
||||
}
|
||||
return 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
});
|
||||
return (result?.result as number) || 0;
|
||||
} catch (error) {
|
||||
console.error('Failed to get ServiceWorker count:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化字节大小显示(兼容旧接口,内部委托给 formatBytes)
|
||||
*
|
||||
* @param bytes 字节数
|
||||
* @returns 格式化后的字符串
|
||||
*/
|
||||
export function formatSize(bytes: number): string {
|
||||
return formatBytes(bytes);
|
||||
}
|
||||
|
||||
export async function clearCookies(url: string): Promise<StorageCleanResult> {
|
||||
try {
|
||||
const cookies = await chrome.cookies.getAll({ url });
|
||||
for (const cookie of cookies) {
|
||||
const protocol = cookie.secure ? 'https:' : 'http:';
|
||||
const domain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain;
|
||||
const cookieUrl = `${protocol}//${domain}${cookie.path}`;
|
||||
await chrome.cookies.remove({
|
||||
url: cookieUrl,
|
||||
name: cookie.name,
|
||||
storeId: cookie.storeId,
|
||||
});
|
||||
}
|
||||
return { success: true, count: cookies.length };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function injectClearLocalStorage(tabId: number): Promise<StorageCleanResult> {
|
||||
try {
|
||||
const [result] = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: () => {
|
||||
const count = localStorage.length;
|
||||
localStorage.clear();
|
||||
return { count };
|
||||
},
|
||||
});
|
||||
if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
|
||||
return { success: true, count: result.result.count };
|
||||
}
|
||||
return { success: false, error: 'No result returned' };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function injectClearSessionStorage(tabId: number): Promise<StorageCleanResult> {
|
||||
try {
|
||||
const [result] = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: () => {
|
||||
const count = sessionStorage.length;
|
||||
sessionStorage.clear();
|
||||
return { count };
|
||||
},
|
||||
});
|
||||
if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
|
||||
return { success: true, count: result.result.count };
|
||||
}
|
||||
return { success: false, error: 'No result returned' };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function injectClearIndexedDB(tabId: number): Promise<StorageCleanResult> {
|
||||
try {
|
||||
const [result] = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: async () => {
|
||||
if (typeof indexedDB.databases === 'function') {
|
||||
const databases = await indexedDB.databases();
|
||||
let count = 0;
|
||||
for (const db of databases) {
|
||||
if (db.name) {
|
||||
const dbName = db.name as string;
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const deleteReq = indexedDB.deleteDatabase(dbName);
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn('IndexedDB delete timeout:', dbName);
|
||||
resolve(); // Timeout, move to next
|
||||
}, 5000);
|
||||
|
||||
deleteReq.onblocked = () => {
|
||||
console.warn('IndexedDB delete blocked:', dbName);
|
||||
clearTimeout(timeout);
|
||||
resolve(); // Blocked, move to next
|
||||
};
|
||||
deleteReq.onsuccess = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
deleteReq.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Failed to delete ${dbName}`));
|
||||
};
|
||||
});
|
||||
count++;
|
||||
} catch (e) {
|
||||
console.error('Delete DB error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { count };
|
||||
}
|
||||
return { error: 'databases_api_unavailable' };
|
||||
},
|
||||
});
|
||||
if (result?.result && typeof result.result === 'object') {
|
||||
if ('error' in result.result) {
|
||||
return { success: false, error: String(result.result.error) };
|
||||
}
|
||||
if ('count' in result.result) {
|
||||
return { success: true, count: result.result.count };
|
||||
}
|
||||
}
|
||||
return { success: false, error: 'No result returned' };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function injectClearCacheStorage(tabId: number): Promise<StorageCleanResult> {
|
||||
try {
|
||||
const [result] = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: async () => {
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
for (const name of cacheNames) {
|
||||
await caches.delete(name);
|
||||
}
|
||||
return { count: cacheNames.length };
|
||||
}
|
||||
return { count: 0 };
|
||||
},
|
||||
});
|
||||
if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
|
||||
return { success: true, count: result.result.count };
|
||||
}
|
||||
return { success: false, error: 'No result returned' };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function injectUnregisterServiceWorkers(tabId: number): Promise<StorageCleanResult> {
|
||||
try {
|
||||
const [result] = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: async () => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
for (const registration of registrations) {
|
||||
await registration.unregister();
|
||||
}
|
||||
return { count: registrations.length };
|
||||
}
|
||||
return { count: 0 };
|
||||
},
|
||||
});
|
||||
if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
|
||||
return { success: true, count: result.result.count };
|
||||
}
|
||||
return { success: false, error: 'No result returned' };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearStorage(
|
||||
tabId: number,
|
||||
url: string,
|
||||
options: StorageCleanerOptions,
|
||||
): Promise<CleaningResult> {
|
||||
const result: CleaningResult = { success: true };
|
||||
|
||||
if (options.localStorage) {
|
||||
result.localStorage = await injectClearLocalStorage(tabId);
|
||||
}
|
||||
|
||||
if (options.sessionStorage) {
|
||||
result.sessionStorage = await injectClearSessionStorage(tabId);
|
||||
}
|
||||
|
||||
if (options.indexedDB) {
|
||||
result.indexedDB = await injectClearIndexedDB(tabId);
|
||||
}
|
||||
|
||||
if (options.cookies) {
|
||||
result.cookies = await clearCookies(url);
|
||||
}
|
||||
|
||||
if (options.cacheStorage) {
|
||||
result.cacheStorage = await injectClearCacheStorage(tabId);
|
||||
}
|
||||
|
||||
if (options.serviceWorkers) {
|
||||
result.serviceWorkers = await injectUnregisterServiceWorkers(tabId);
|
||||
}
|
||||
|
||||
// Check if any operation failed
|
||||
const failures = Object.values(result).filter(
|
||||
(r): r is StorageCleanResult => r?.success === false,
|
||||
);
|
||||
|
||||
if (failures.length > 0) {
|
||||
result.success = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatCleaningResult(
|
||||
result: CleaningResult,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
const optionKeys: (keyof StorageCleanerOptions)[] = [
|
||||
'localStorage',
|
||||
'sessionStorage',
|
||||
'indexedDB',
|
||||
'cookies',
|
||||
'cacheStorage',
|
||||
'serviceWorkers',
|
||||
];
|
||||
|
||||
for (const key of optionKeys) {
|
||||
const r = result[key];
|
||||
if (r?.success && r.count > 0) {
|
||||
parts.push(`${r.count} ${t(`storageCleaner:options.${key}`)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return t('storageCleaner:noDataToClean');
|
||||
}
|
||||
|
||||
return t('storageCleaner:cleanedSummary', { items: parts.join(', ') });
|
||||
}
|
||||
|
||||
export function isEmptyResult(result: CleaningResult): boolean {
|
||||
const values = Object.values(result).filter(
|
||||
(r): r is StorageCleanResult => r?.success === true && r.count > 0,
|
||||
);
|
||||
return values.length === 0;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { formatBytes } from './format';
|
||||
|
||||
/**
|
||||
* 文本统计信息接口
|
||||
*/
|
||||
export interface TextStats {
|
||||
/** 字符数(包含空格和特殊字符) */
|
||||
characters: number;
|
||||
/** 单词数 */
|
||||
words: number;
|
||||
/** 行数 */
|
||||
lines: number;
|
||||
/** 字节大小 */
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算文本统计信息
|
||||
*
|
||||
* @param text 输入的文本内容
|
||||
* @returns 统计结果对象
|
||||
*/
|
||||
export function getTextStats(text: string): TextStats {
|
||||
if (!text) {
|
||||
return { characters: 0, words: 0, lines: 0, bytes: 0 };
|
||||
}
|
||||
|
||||
// 1. 字符数:统计总字符数量
|
||||
const characters = text.length;
|
||||
|
||||
// 2. 单词数:使用 Intl.Segmenter 识别单词边界
|
||||
// 这能很好地处理中英文混合文本。中文会按词组切分,英文按单词切分。
|
||||
let words = 0;
|
||||
try {
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
|
||||
const segments = segmenter.segment(text);
|
||||
for (const segment of segments) {
|
||||
// isWordLike 为 true 表示该片段是“类词”的(非空格、非标点)
|
||||
if (segment.isWordLike) {
|
||||
words++;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 降级方案:如果不支持 Intl.Segmenter,使用正则匹配英文单词
|
||||
// 但对中文支持较差
|
||||
const englishWords = text.match(/\b\w+\b/g) || [];
|
||||
const chineseChars = text.match(/[\u4e00-\u9fa5]/g) || [];
|
||||
words = englishWords.length + chineseChars.length;
|
||||
}
|
||||
|
||||
// 3. 行数:统计换行符数量
|
||||
// 空字符串已在上方处理。非空文本至少有一行。
|
||||
const lines = text.split('\n').length;
|
||||
|
||||
// 4. 字节大小:计算文本内容的字节数 (UTF-8)
|
||||
const bytes = new TextEncoder().encode(text).length;
|
||||
|
||||
return { characters, words, lines, bytes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化字节大小显示(兼容旧接口,内部委托给 formatBytes)
|
||||
*
|
||||
* @param bytes 字节数
|
||||
* @returns 格式化后的字符串
|
||||
*/
|
||||
export function formatByteSize(bytes: number): string {
|
||||
return formatBytes(bytes);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import type { ContextMenuPendingData, PageType } from '@/types/storage';
|
||||
|
||||
const STORAGE_KEY = 'contextMenu/pendingData' as const;
|
||||
|
||||
/** 右键菜单数据过期时间(毫秒) */
|
||||
export const CONTEXT_MENU_DATA_EXPIRY_MS = 5000;
|
||||
|
||||
export interface UseContextMenuDataOptions {
|
||||
/** 当前页面的功能标识 */
|
||||
featureKey: PageType;
|
||||
/** 收到数据时的回调函数 */
|
||||
onData: (payload: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义 Hook:处理右键菜单传递的数据
|
||||
*
|
||||
* 使用方式:
|
||||
* 1. 在页面组件中调用此 Hook
|
||||
* 2. 传入当前页面的 featureKey 和数据处理回调
|
||||
* 3. Hook 会自动从 storage 中读取并消费匹配的数据
|
||||
*/
|
||||
export function useContextMenuData({ featureKey, onData }: UseContextMenuDataOptions): void {
|
||||
const checkAndConsumeData = useCallback(async () => {
|
||||
try {
|
||||
const data = await storageUtil.get(STORAGE_KEY, undefined);
|
||||
|
||||
if (!data) return;
|
||||
|
||||
if (data.featureKey !== featureKey) return;
|
||||
|
||||
if (Date.now() - data.timestamp > CONTEXT_MENU_DATA_EXPIRY_MS) {
|
||||
await storageUtil.remove(STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
await storageUtil.remove(STORAGE_KEY);
|
||||
|
||||
onData(data.payload);
|
||||
} catch (error) {
|
||||
console.error('[useContextMenuData] 处理右键菜单数据失败:', error);
|
||||
}
|
||||
}, [featureKey, onData]);
|
||||
|
||||
useEffect(() => {
|
||||
checkAndConsumeData();
|
||||
}, [checkAndConsumeData]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
|
||||
if (changes[STORAGE_KEY]) {
|
||||
const newData = changes[STORAGE_KEY].newValue as ContextMenuPendingData | null;
|
||||
if (newData && newData.featureKey === featureKey) {
|
||||
checkAndConsumeData();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chrome.storage.onChanged.addListener(handleStorageChange);
|
||||
return () => chrome.storage.onChanged.removeListener(handleStorageChange);
|
||||
}, [featureKey, checkAndConsumeData]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存右键菜单数据到 storage
|
||||
* 由 RouterProvider 或入口组件调用
|
||||
*/
|
||||
export async function saveContextMenuData(
|
||||
data: Omit<ContextMenuPendingData, 'timestamp'>,
|
||||
): Promise<void> {
|
||||
const pendingData: ContextMenuPendingData = {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
await storageUtil.set(STORAGE_KEY, pendingData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除右键菜单待处理数据
|
||||
*/
|
||||
export async function clearContextMenuData(): Promise<void> {
|
||||
await storageUtil.remove(STORAGE_KEY);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* useDebounce Hook - 防抖值
|
||||
*
|
||||
* @param value - 需要防抖的值
|
||||
* @param delay - 延迟时间(毫秒)
|
||||
* @returns 防抖后的值
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import type { StorageSchema } from '@/types/storage';
|
||||
|
||||
/**
|
||||
* 从 localStorage 获取同步快照(用于消除异步加载产生的首屏闪烁)
|
||||
*/
|
||||
const getSyncSnapshot = <T>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
validator?: (val: unknown) => val is T,
|
||||
): T => {
|
||||
try {
|
||||
const val = localStorage.getItem(`snapshot/${key}`);
|
||||
if (!val) return defaultValue;
|
||||
const parsed = JSON.parse(val) as unknown;
|
||||
if (validator) {
|
||||
return validator(parsed) ? parsed : defaultValue;
|
||||
}
|
||||
return (parsed as T) ?? defaultValue;
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
export const useStorageState = <K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
defaultValue: StorageSchema[K],
|
||||
validator?: (val: unknown) => val is StorageSchema[K],
|
||||
) => {
|
||||
const [value, setValue] = useState<StorageSchema[K]>(() =>
|
||||
getSyncSnapshot(key as string, defaultValue, validator),
|
||||
);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const hasLoadedFromStorage = useRef(false);
|
||||
|
||||
// Only load from storage once on mount
|
||||
useEffect(() => {
|
||||
if (hasLoadedFromStorage.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadState = async () => {
|
||||
try {
|
||||
const savedValue = await storageUtil.get(key, defaultValue);
|
||||
if (cancelled) return;
|
||||
if (savedValue !== undefined) {
|
||||
if (validator) {
|
||||
setValue(validator(savedValue) ? savedValue : defaultValue);
|
||||
} else {
|
||||
setValue(savedValue);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`加载状态失败 (${key}):`, error);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsInitialized(true);
|
||||
hasLoadedFromStorage.current = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadState().catch(console.error);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [defaultValue, key, validator]);
|
||||
|
||||
// Save to storage and localStorage snapshot when value changes (after initial load)
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
const saveState = async () => {
|
||||
try {
|
||||
await storageUtil.set(key, value);
|
||||
localStorage.setItem(`snapshot/${key}`, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.error(`保存状态失败 (${key}):`, error);
|
||||
}
|
||||
};
|
||||
|
||||
saveState().catch(console.error);
|
||||
}, [value, isInitialized, key]);
|
||||
|
||||
return [value, setValue, isInitialized] as const;
|
||||
};
|
||||
Reference in New Issue
Block a user