refactor: 优化组件并添加测试覆盖

- 创建 CLAUDE.md 项目开发指导文档
- 提取公共常量到 components/constants.ts
- 修复 TimestampToDatetime 重复插件扩展问题
- 修复 DatetimeToTimestamp 时区解析问题
- 优化 RoutePersistence 移除不必要依赖
- 添加 Vitest 测试框架配置
- 为所有组件编写单元测试 (16个测试用例)
- 更新 .gitignore 忽略测试结果目录

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
雨霖铃
2026-03-16 22:10:02 +08:00
parent c9f8fe4d29
commit 9ff6b8985c
18 changed files with 2208 additions and 130 deletions
+1
View File
@@ -12,6 +12,7 @@ node_modules
stats.html
stats-*.json
.wxt
.vitest
# Editor directories and files
.vscode/*
+147
View File
@@ -0,0 +1,147 @@
```
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
这是一个基于 WXT 框架的浏览器扩展项目,提供测试工具功能,包括时间戳转换、用户操作录制与回放等。
## 核心命令
### 开发相关
- `npm run dev` - 启动 Chrome 浏览器的开发模式
- `npm run dev:firefox` - 启动 Firefox 浏览器的开发模式
- `npm run build` - 构建 Chrome 浏览器的生产版本
- `npm run build:firefox` - 构建 Firefox 浏览器的生产版本
- `npm run zip` - 打包 Chrome 扩展
- `npm run zip:firefox` - 打包 Firefox 扩展
- `npm run compile` - TypeScript 类型检查(不生成文件)
- `npm run lint` - 运行 ESLint 检查
### 依赖与准备
- `npm install` - 安装依赖
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
- `prepare` 钩子会初始化 Husky Git 钩子
## 项目架构
### 技术栈
- **框架**: WXT (Web Extension Toolkit) - 浏览器扩展开发框架
- **前端**: React 19 + TypeScript
- **UI 库**: Material UI (MUI)
- **状态管理**: React Hooks
- **数据库**: Dexie.js (IndexedDB)
- **录制回放**: rrweb
- **路由**: React Router DOM
### 目录结构
```
├── components/ # 可复用 UI 组件
│ ├── CopyButton.tsx # 复制按钮组件
│ ├── DatetimeToTimestamp.tsx # 日期转时间戳组件
│ ├── Navbar.tsx # 导航栏组件
│ ├── RoutePersistence.tsx # 路由持久化组件
│ ├── TimestampExecution.tsx # 时间戳执行组件
│ └── TimestampToDatetime.tsx # 时间戳转日期组件
├── entrypoints/ # 浏览器扩展入口点
│ ├── background.ts # 后台脚本(主进程)
│ ├── content.ts # 内容脚本(注入到页面)
│ ├── offscreen/ # 离屏文档(用于长时间运行任务)
│ ├── popup/ # 扩展弹窗界面
│ │ ├── App.tsx # 弹窗主应用
│ │ ├── main.tsx # 弹窗入口
│ │ └── pages/ # 弹窗页面
│ │ ├── RecordeReplayPage.tsx # 录制回放页面
│ │ ├── TestPage.tsx # 测试页面
│ │ └── TimestampPage.tsx # 时间戳工具页面
│ └── options/ # 选项页面(未列出)
├── assets/ # 静态资源
├── utils/ # 工具函数
│ ├── chromeStorage.ts # Chrome 存储工具
│ ├── dayjs.ts # 日期处理工具
│ ├── messages.tsx # 消息通信工具
│ ├── recordEventsDb.ts # IndexedDB 数据库工具(录制事件存储)
│ ├── recordUtils.tsx # 录制工具函数
│ ├── tabUtils.ts # 标签页工具
│ └── useRecorder.tsx # 录制器 Hook
├── types/ # 类型定义
│ └── storage.d.ts # 存储相关类型
├── public/ # 公共资源
├── package.json # 项目依赖和脚本
├── tsconfig.json # TypeScript 配置
├── wxt.config.ts # WXT 配置
└── web-ext.config.ts # WebExtensions 配置
````
### 核心功能实现
#### 1. 时间戳转换工具
- 位置: `components/` 目录下的时间戳相关组件
- 依赖: dayjs 库进行日期处理
- 功能: 支持日期与时间戳的双向转换,支持多种格式
#### 2. 录制与回放功能
- 位置: `utils/useRecorder.tsx` (核心录制逻辑)、`utils/recordUtils.tsx` (工具函数)
- 依赖: rrweb 库
- 存储: IndexedDB (Dexie.js) - `utils/recordEventsDb.ts`
- 特点: 支持分块存储录制事件,优化性能
#### 3. 通信系统
- 位置: `utils/messages.tsx`
- 机制: 使用 `@webext-core/messaging` 库实现
- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗 ↔ 离屏文档
#### 4. 数据存储
- Chrome Storage API: `utils/chromeStorage.ts` (用于配置等小数据)
- IndexedDB: `utils/recordEventsDb.ts` (用于存储大量录制事件)
### 关键配置文件
#### wxt.config.ts
- 配置 WXT 框架参数
- 启用 React 模块
- 配置浏览器扩展权限
- Vite 构建配置(使用 Terser 压缩,强制 ASCII 编码)
#### manifest 权限
```typescript
permissions: [
'storage', // 存储权限
'unlimitedStorage', // 无限制存储
'clipboardWrite', // 剪贴板写入
'activeTab', // 当前标签页
'scripting', // 脚本注入
'tabs', // 标签页管理
'offscreen', // 离屏文档
'downloads', // 下载管理
'debugger', // 调试器
],
host_permissions: ['<all_urls>'] // 访问所有网站
````
## 开发注意事项
### 扩展入口点
- **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务
- **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互
- **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示
- **离屏文档**: `entrypoints/offscreen/main.tsx` - 处理长时间运行的任务(如录制)
### 浏览器兼容性
- 支持 Chrome 和 Firefox 浏览器
- 使用 WXT 框架抽象浏览器差异
### 代码质量
- 使用 ESLint 进行代码检查
- Husky 用于 Git 钩子管理
- Lint-staged 确保暂存文件符合规范
```
```
+3 -27
View File
@@ -12,32 +12,7 @@ import {
Box,
SelectChangeEvent,
} from '@mui/material';
const TIME_ZONE_LIST = [
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Anchorage',
'America/Honolulu',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Europe/Moscow',
'Asia/Tokyo',
'Asia/Shanghai',
'Asia/Hong_Kong',
'Asia/Singapore',
'Asia/Dubai',
'Asia/Kolkata',
'Australia/Sydney',
'Pacific/Auckland',
];
const TIMESTAMP_UNITS = [
{ value: 'milliseconds', label: '毫秒 (ms)' },
{ value: 'seconds', label: '秒 (s)' },
];
import { TIME_ZONE_LIST, TIMESTAMP_UNITS } from './constants';
export function DatetimeToTimestamp() {
const [dateValue, setDateValue] = useState(() => dayjs().format('YYYY/MM/DD HH:mm:ss'));
@@ -52,7 +27,8 @@ export function DatetimeToTimestamp() {
setError('请输入有效的日期时间');
return '';
}
const timestamp = dayjs.tz(currentDate, zone);
// 使用 tz 方法直接解析带时区的日期
const timestamp = dayjs.tz(currentDate, 'YYYY/MM/DD HH:mm:ss', zone);
if (!timestamp.isValid()) {
setError('无效的日期时间格式');
return '';
+3 -5
View File
@@ -17,7 +17,6 @@ const RoutePersistence = () => {
if (lastRoute && lastRoute !== '/' && location.pathname === '/') {
navigate(lastRoute, { replace: true });
console.log('跳转路由', lastRoute);
}
} catch (err) {
console.error('恢复路由失败', err);
@@ -26,17 +25,16 @@ const RoutePersistence = () => {
}
};
restoreRoute().then(() => console.info('恢复路由成功'));
}, [location, navigate]);
restoreRoute();
}, [navigate, location.pathname]);
useEffect(() => {
const saveRoute = async () => {
if (!isRestored.current) return;
await storageUtil.set('app/lastRoute', location.pathname);
console.log('保存路由', location.pathname);
};
saveRoute().then(() => console.info('保存路由成功'));
saveRoute();
}, [location]);
return null;
+2 -32
View File
@@ -1,5 +1,5 @@
import React, { useState, useCallback } from 'react';
import dayjs from 'dayjs';
import dayjs from '@/utils/dayjs';
import {
Button,
TextField,
@@ -12,37 +12,7 @@ import {
Box,
SelectChangeEvent,
} from '@mui/material';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
const TIME_ZONE_LIST = [
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Anchorage',
'America/Honolulu',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Europe/Moscow',
'Asia/Tokyo',
'Asia/Shanghai',
'Asia/Hong_Kong',
'Asia/Singapore',
'Asia/Dubai',
'Asia/Kolkata',
'Australia/Sydney',
'Pacific/Auckland',
];
const TIMESTAMP_UNITS = [
{ value: 'milliseconds', label: '毫秒 (ms)' },
{ value: 'seconds', label: '秒 (s)' },
];
import { TIME_ZONE_LIST, TIMESTAMP_UNITS } from './constants';
export function TimestampToDatetime() {
const [timestampValue, setTimestampValue] = useState(() => dayjs().valueOf().toString());
+52
View File
@@ -0,0 +1,52 @@
import { render, screen, fireEvent } from '@testing-library/react';
import CopyButton from '../CopyButton';
import { vi } from 'vitest';
describe('CopyButton', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it('应该正确渲染默认文本', () => {
render(<CopyButton textToCopy="test" />);
expect(screen.getByText('复制')).toBeInTheDocument();
});
it('应该正确渲染自定义按钮文本', () => {
render(<CopyButton textToCopy="test" buttonText="Custom Copy" />);
expect(screen.getByText('Custom Copy')).toBeInTheDocument();
});
it('点击按钮时应该复制文本到剪贴板', async () => {
const writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: writeTextMock },
writable: true,
});
render(<CopyButton textToCopy="test content" />);
const button = screen.getByText('复制');
fireEvent.click(button);
vi.advanceTimersByTime(300);
expect(writeTextMock).toHaveBeenCalledWith('test content');
});
it('当没有提供要复制的文本时,应该在控制台警告', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
render(<CopyButton textToCopy="" />);
const button = screen.getByText('复制');
fireEvent.click(button);
expect(consoleWarnSpy).toHaveBeenCalledWith('没有提供要复制的文本');
consoleWarnSpy.mockRestore();
});
});
@@ -0,0 +1,17 @@
import { render, screen } from '@testing-library/react';
import { DatetimeToTimestamp } from '../DatetimeToTimestamp';
describe('DatetimeToTimestamp', () => {
it('应该正确渲染组件', () => {
render(<DatetimeToTimestamp />);
expect(screen.getByLabelText('输入日期时间')).toBeInTheDocument();
expect(screen.getByText('转换')).toBeInTheDocument();
expect(screen.getByLabelText('转换结果')).toBeInTheDocument();
});
it('应该初始化为当前日期时间', () => {
render(<DatetimeToTimestamp />);
const input = screen.getByLabelText('输入日期时间') as HTMLInputElement;
expect(input.value).toMatch(/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}$/);
});
});
+47
View File
@@ -0,0 +1,47 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import Navbar from '../Navbar';
import { vi } from 'vitest';
const mockItems = [
{ path: '/', label: '首页', element: <div></div> },
{ path: '/timestamp', label: '时间戳', element: <div></div> },
];
const renderWithRouter = (initialPath = '/', items = mockItems) => {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<Navbar items={items} />
</MemoryRouter>,
);
};
describe('Navbar', () => {
beforeEach(() => {
// 模拟 useMediaQuery 以确保大屏幕行为
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: query === '(min-width:768px)',
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
});
});
it('应该正确渲染所有导航项(大屏幕)', () => {
renderWithRouter();
mockItems.forEach((item) => {
expect(screen.getByText(item.label)).toBeInTheDocument();
});
});
it('没有提供 items 时应该正常渲染', () => {
render(
<MemoryRouter>
<Navbar />
</MemoryRouter>,
);
expect(true).toBe(true);
});
});
@@ -0,0 +1,40 @@
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import RoutePersistence from '../RoutePersistence';
import { storageUtil } from '@/utils/chromeStorage';
import { vi } from 'vitest';
vi.mock('@/utils/chromeStorage', () => ({
storageUtil: {
get: vi.fn(),
set: vi.fn(),
},
}));
const renderComponentOnly = (initialPath = '/') => {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<RoutePersistence />
</MemoryRouter>,
);
};
describe('RoutePersistence', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('组件应该正常渲染且返回 null', () => {
const { container } = renderComponentOnly();
expect(container.firstChild).toBeNull();
});
it('当在根路由时应该从存储中恢复路由', async () => {
const mockGet = storageUtil.get as vi.Mock;
mockGet.mockResolvedValue('/timestamp');
await renderComponentOnly('/');
expect(mockGet).toHaveBeenCalledWith('app/lastRoute');
});
});
@@ -0,0 +1,39 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { TimestampExecution } from '../TimestampExecution';
import { vi } from 'vitest';
describe('TimestampExecution', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('应该正确渲染组件', () => {
render(<TimestampExecution />);
expect(screen.getByText('切换单位')).toBeInTheDocument();
expect(screen.getByText('复制')).toBeInTheDocument();
expect(screen.getByText('停止')).toBeInTheDocument();
});
it('初始状态应该显示毫秒单位', () => {
render(<TimestampExecution />);
expect(screen.getByText('毫秒')).toBeInTheDocument();
});
it('点击切换单位按钮应该切换为秒显示', () => {
render(<TimestampExecution />);
const toggleUnitButton = screen.getByText('切换单位');
fireEvent.click(toggleUnitButton);
expect(screen.getByText('秒')).toBeInTheDocument();
});
it('点击停止按钮应该停止时间戳自动更新', () => {
render(<TimestampExecution />);
const toggleButton = screen.getByText('停止');
fireEvent.click(toggleButton);
expect(screen.getByText('开始')).toBeInTheDocument();
});
});
@@ -0,0 +1,17 @@
import { render, screen } from '@testing-library/react';
import { TimestampToDatetime } from '../TimestampToDatetime';
describe('TimestampToDatetime', () => {
it('应该正确渲染组件', () => {
render(<TimestampToDatetime />);
expect(screen.getByLabelText('输入时间戳')).toBeInTheDocument();
expect(screen.getByText('转换')).toBeInTheDocument();
expect(screen.getByLabelText('转换结果')).toBeInTheDocument();
});
it('应该初始化为当前时间戳', () => {
render(<TimestampToDatetime />);
const input = screen.getByLabelText('输入时间戳') as HTMLInputElement;
expect(input.value).toMatch(/^\d{13}$/);
});
});
+28
View File
@@ -0,0 +1,28 @@
export const TIME_ZONE_LIST = [
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Anchorage',
'America/Honolulu',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Europe/Moscow',
'Asia/Tokyo',
'Asia/Shanghai',
'Asia/Hong_Kong',
'Asia/Singapore',
'Asia/Dubai',
'Asia/Kolkata',
'Australia/Sydney',
'Pacific/Auckland',
] as const;
export const TIMESTAMP_UNITS = [
{ value: 'milliseconds', label: '毫秒 (ms)' },
{ value: 'seconds', label: '秒 (s)' },
] as const;
export type TimeZone = (typeof TIME_ZONE_LIST)[number];
export type TimestampUnit = (typeof TIMESTAMP_UNITS)[number]['value'];
+1733 -64
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -14,7 +14,10 @@
"compile": "tsc --noEmit",
"postinstall": "wxt prepare",
"prepare": "husky",
"lint": "eslint . --max-warnings=0"
"lint": "eslint . --max-warnings=0",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@emotion/react": "^11.14.0",
@@ -49,17 +52,20 @@
"@types/webextension-polyfill": "^0.12.4",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"@vitejs/plugin-react": "^6.0.1",
"@wxt-dev/module-react": "^1.1.5",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.2.0",
"husky": "^9.1.7",
"jsdom": "^29.0.0",
"lint-staged": "^16.2.7",
"prettier": "^3.8.1",
"terser": "^5.46.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"vitest": "^4.1.0",
"wxt": "^0.20.6"
}
}
+9 -1
View File
@@ -52,5 +52,13 @@
"components",
"eslint.config.ts"
],
"exclude": ["node_modules", ".wxt"]
"exclude": [
"node_modules",
".wxt",
"components/__tests__",
"**/*.test.tsx",
"**/*.test.ts",
"vitest.config.ts",
"vitest.setup.ts"
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["vitest/globals", "node"],
"jsx": "react-jsx",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true
},
"include": [
"components/**/*.test.tsx",
"components/**/*.tsx",
"components/**/*.ts",
"utils/**/*.ts",
"utils/**/*.tsx",
"vitest.setup.ts"
]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './vitest.setup.ts',
cache: {
dir: resolve(__dirname, '.vitest'),
},
tsconfig: './tsconfig.vitest.json',
},
resolve: {
alias: {
'@': resolve(__dirname, '.'),
},
},
});
+24
View File
@@ -0,0 +1,24 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// 模拟 chrome storage API
Object.defineProperty(global, 'chrome', {
value: {
storage: {
local: {
get: vi.fn(),
set: vi.fn(),
remove: vi.fn(),
},
},
},
writable: true,
});
// 模拟 navigator.clipboard
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: vi.fn().mockResolvedValue(undefined),
},
writable: true,
});