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:
雨霖铃
2026-05-28 20:20:45 +08:00
parent 9903483f42
commit b79fe7dd49
166 changed files with 86 additions and 631 deletions
+358
View File
@@ -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('应该识别 WebPRIFF....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('不应将 WAVRIFF 容器但非 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);
});
});
+139
View File
@@ -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']);
});
});
});
+167
View File
@@ -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();
});
});
});
+57
View File
@@ -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);
});
});
});
+189
View File
@@ -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' },
});
});
});
});
+94
View File
@@ -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);
});
});
+71
View File
@@ -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);
});
});
+44
View File
@@ -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');
});
});
+265
View File
@@ -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('![desc](image.png)');
});
it('converts images with title', () => {
const result = htmlToMarkdown('<img src="image.png" alt="desc" title="Title" />');
expect(result.markdown).toContain('![desc](image.png "Title")');
});
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');
});
});
+156
View File
@@ -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();
});
});
+88
View File
@@ -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('[]');
});
});
+106
View File
@@ -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');
});
});
+108
View File
@@ -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');
});
});
+64
View File
@@ -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();
});
});
});
+177
View File
@@ -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 &lt;script&gt;');
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();
});
});
+163
View File
@@ -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();
});
});
});
+111
View File
@@ -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;;');
});
});
});
+133
View File
@@ -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();
});
});
});
+170
View File
@@ -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);
});
});
+379
View File
@@ -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);
}
+81
View File
@@ -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();
}
+43
View File
@@ -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();
+81
View File
@@ -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;
}
}
+31
View File
@@ -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;
}
}
+123
View File
@@ -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: '无法获取有效数据' };
}
+11
View File
@@ -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;
+24
View File
@@ -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]}`;
}
+317
View File
@@ -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 `![${alt}](${src} "${imgTitle}")`;
}
return `![${alt}](${src})`;
}
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);
}
+125
View File
@@ -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);
}
}
+218
View File
@@ -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 };
}
+161
View File
@@ -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 };
}
+129
View File
@@ -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);
}
}
+287
View File
@@ -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 内容吧!*`;
+75
View File
@@ -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]>;
}
}
+37
View File
@@ -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 };
}
}
+430
View File
@@ -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;
}
+69
View File
@@ -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);
}
+85
View File
@@ -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);
}
+24
View File
@@ -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;
}
+88
View File
@@ -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;
};