diff --git a/spec/README.md b/spec/README.md new file mode 100644 index 0000000..2a417f6 --- /dev/null +++ b/spec/README.md @@ -0,0 +1,7 @@ +# Spec 目录 + +功能规格、修复方案与验收标准文档。 + +| 文档 | 状态 | 说明 | +| -------------------------------------------------------------------------------- | ----------------- | ---------------------------------------------------- | +| [storage-cleaner/indexeddb-fix-plan.md](./storage-cleaner/indexeddb-fix-plan.md) | ✅ Phase 3 已完成 | Storage Cleaner IndexedDB 清理逻辑修复方案与验收标准 | diff --git a/spec/storage-cleaner/indexeddb-fix-plan.md b/spec/storage-cleaner/indexeddb-fix-plan.md new file mode 100644 index 0000000..384d3e6 --- /dev/null +++ b/spec/storage-cleaner/indexeddb-fix-plan.md @@ -0,0 +1,435 @@ +# Storage Cleaner — IndexedDB 修复方案与验收标准 + +> 创建时间: 2026-06-25 +> 状态: ✅ Phase 3 已完成(2026-06-25) +> 关联模块: `src/utils/storageCleaner.ts` +> 前置审查: Code Review(`storageCleaner.ts` 修改版) + +## 背景与目标 + +本次修复针对 `storageCleaner.ts` 中 IndexedDB 清理逻辑及错误处理链路的审查结论,按优先级分三阶段实施。 + +| 问题域 | 现状 | 目标 | +| ------------------- | ---------------------------------------------- | --------------------------------- | +| IndexedDB fallback | `store.clear()` 完成后立即 `db.close()` | 等 transaction commit 后再关闭 | +| deleteDatabase 超时 | 超时后仍可能触发 `onsuccess`,与 fallback 并发 | 单次删除生命周期内只 resolve 一次 | +| 部分成功 | 多 DB 部分失败时 `count` 丢失 | 失败结果保留已清理数量 | +| 代码结构 | `runScript` / `runCleanScript` 重复 | 统一 executeScript 入口 | +| 测试 | 缺多 DB 混合场景 | 补单元测试覆盖 | + +--- + +## Phase 1 — 合并前必做(P1) + +### 1.1 等待 IndexedDB transaction 完成后再关闭连接 + +#### 问题 + +`clearObjectStores` 在 `Promise.all(clearStore...)` 结束后立刻 `db.close()`。单个 `clearReq.onsuccess` 只表示 request 完成,transaction 可能尚未 commit,存在清空被回滚的风险。 + +#### 根因 + +IndexedDB 规范中,transaction 的持久化以 `transaction.oncomplete` 为准,而非单个 request 的 `onsuccess`。 + +#### 修复方案 + +在注入脚本内的 `clearObjectStores` 中,增加 `waitForTransaction` 辅助函数: + +```typescript +const waitForTransaction = (tx: IDBTransaction): Promise => + new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error('Transaction failed')); + tx.onabort = () => reject(tx.error ?? new Error('Transaction aborted')); + }); +``` + +修改 `openReq.onsuccess` 分支: + +```typescript +const transaction = db.transaction(storeNames, 'readwrite'); +const errors = ( + await Promise.all( + storeNames.map((storeName) => clearStore(transaction.objectStore(storeName), storeName)), + ) +).filter((error): error is string => Boolean(error)); + +try { + await waitForTransaction(transaction); +} catch { + db.close(); + resolve({ + success: false, + errors: [`清空 IndexedDB 失败(${dbName}),请刷新后重试`], + }); + return; +} + +db.close(); +resolve({ success: errors.length === 0, errors }); +``` + +#### 涉及文件 + +- `src/utils/storageCleaner.ts` — `injectClearIndexedDB` 内 `clearObjectStores` + +#### 新增测试 + +```typescript +it('should wait for transaction complete before closing db', async () => { + // mock: clear onsuccess 先于 transaction.oncomplete 触发 + // 断言 db.close 在 transaction.oncomplete 之后调用 +}); +``` + +--- + +### 1.2 消除 deleteDatabase 超时竞态 + +#### 问题 + +超时 `resolve('timeout')` 后,`deleteReq.onsuccess` 仍可能触发;此时 fallback 的 `indexedDB.open` 与进行中的 `deleteDatabase` 可能并发,行为未定义。 + +#### 修复方案 + +为每个 DB 删除引入 **单次 settle** 状态: + +```typescript +const waitForDeleteDatabase = (dbName: string, timeoutMs: number) => + new Promise<'deleted' | 'blocked' | 'timeout' | 'error'>((resolve) => { + let settled = false; + const settle = (status: 'deleted' | 'blocked' | 'timeout' | 'error') => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(status); + }; + + const deleteReq = indexedDB.deleteDatabase(dbName); + const timeout = setTimeout(() => { + console.warn('IndexedDB delete timeout:', dbName); + settle('timeout'); + }, timeoutMs); + + deleteReq.onblocked = () => { + console.warn('IndexedDB delete blocked:', dbName); + settle('blocked'); + }; + deleteReq.onsuccess = () => settle('deleted'); + deleteReq.onerror = () => settle('error'); + }); +``` + +#### timeout / blocked 后的 fallback 策略 + +| 状态 | 行为 | +| --------- | ------------------------------------------------------------------ | +| `blocked` | 立即 fallback `clearObjectStores`(页面仍占用连接,open 通常可行) | +| `timeout` | 先 `await delay(100~200ms)` 再 fallback,降低与 delete 并发概率 | +| `error` | 不 fallback,直接报错 | + +#### 涉及文件 + +- `src/utils/storageCleaner.ts` — 替换现有 `new Promise` 删除逻辑 + +#### 新增测试 + +```typescript +it('should ignore late onsuccess after delete timeout', async () => { + // deleteBehavior: timeout,5000ms 后 resolve timeout + // 6000ms 后再触发 onsuccess + // 断言:只走 fallback 一次,count 不因 late onsuccess 重复 +1 +}); +``` + +--- + +## Phase 2 — 建议同 PR 或紧接 follow-up(P2) + +### 2.1 IndexedDB 部分成功时保留 count + +#### 问题 + +多 DB 场景返回 `{ count: 2, errors: ['...'] }` 时,`runCleanScript` 只返回 `{ success: false, error }`,用户看不到已清理 2 个库。 + +#### 修复方案(推荐) + +扩展失败分支类型,可选 `count`: + +```typescript +// src/types/storage.d.ts +export type StorageCleanResult = + | { success: true; count: number } + | { success: false; error: string; count?: number }; // 部分成功时的已清理数 +``` + +修改 `runCleanScript`: + +```typescript +if (raw.errors?.length) { + const errorMsg = raw.errors.join('\n'); + const partialHint = raw.count > 0 ? `(已成功清理 ${raw.count} 个数据库,但部分失败)\n` : ''; + return { + success: false, + error: partialHint + errorMsg, + ...(raw.count > 0 ? { count: raw.count } : {}), + }; +} +``` + +#### UI 层(可选增强) + +`CleaningResult.tsx` 失败时若 `result.indexedDB?.count` 存在,可展示部分成功提示(非必须,error 字符串已含 hint 即可)。 + +#### 涉及文件 + +- `src/types/storage.d.ts` +- `src/utils/storageCleaner.ts` — `runCleanScript` +- `src/utils/__tests__/storageCleaner.test.ts` +- (可选)`src/pages/StorageCleaner/components/CleaningResult.tsx` + +#### 新增测试 + +```typescript +it('should preserve partial count when some IndexedDB databases fail', async () => { + // 3 个 DB:2 成功删除,1 blocked 且 fallback 失败 + // expect: success false, count 2, error 含「已成功清理 2 个」 +}); +``` + +--- + +### 2.2 统一 executeScript 调用入口 + +#### 问题 + +`runScript` 与 `runCleanScript` 各自调用 `browser.scripting.executeScript`,行为不一致(吞错 vs 抛错)。 + +#### 修复方案 + +抽取底层函数: + +```typescript +type ExecuteScriptMode = 'fallback' | 'throw'; + +async function executeInTab( + tabId: number, + func: () => T | Promise, + options: { errorLabel: string; mode: 'fallback'; fallback: T }, +): Promise; +async function executeInTab( + tabId: number, + func: () => T | Promise, + options: { errorLabel: string; mode: 'throw' }, +): Promise; +async function executeInTab(...) { + try { + const [result] = await browser.scripting.executeScript({ target: { tabId }, func }); + return (result?.result as T) ?? (options.mode === 'fallback' ? options.fallback : undefined as T); + } catch (error) { + console.error(`Failed to ${options.errorLabel}:`, error); + if (options.mode === 'throw') throw error; + return options.fallback; + } +} +``` + +- `runScript` → `executeInTab(..., { mode: 'fallback', fallback })` +- `runCleanScript` → `executeInTab(..., { mode: 'throw' })` + 结果解析 + +#### 涉及文件 + +- `src/utils/storageCleaner.ts` + +#### 验收 + +现有 11 个测试全部通过,无行为回归。 + +--- + +### 2.3 错误信息分隔符统一 + +#### 问题 + +`runCleanScript` 用 `'; '` 拼接,`clearStorage` 的 `result.error` 用 `'\n'`,UI 用 `break-all` 展示,多错误时可读性不一致。 + +#### 修复方案 + +IndexedDB 内部多错误统一改为 `'\n'`: + +```typescript +error: raw.errors.join('\n'); +``` + +#### 涉及文件 + +- `src/utils/storageCleaner.ts` +- 相关测试断言(若有 `'; '` 期望) + +--- + +### 2.4 补充多 DB 混合场景测试 + +| 用例 | 输入 | 期望 | +| ------------------ | ------------------------------ | -------------------------------------------- | +| 全部成功 | 3 DB,均 delete success | `success: true, count: 3` | +| 部分 fallback 成功 | 2 success + 1 blocked→clear OK | `success: true, count: 3` | +| 部分失败 | 2 success + 1 error | `success: false, count: 2, error 含失败库名` | +| 空库列表 | `databases()` 返回 `[]` | `success: true, count: 0` | + +#### 涉及文件 + +- `src/utils/__tests__/storageCleaner.test.ts` +- 扩展 `createIndexedDBMock` 支持 per-db 不同 `deleteBehavior` + +--- + +## Phase 3 — 可选优化(P3) + +### 3.1 超时常量提升到模块级 + +```typescript +// src/utils/storageCleaner.ts 或 src/pages/StorageCleaner/constants.ts +const INDEXED_DB_DELETE_TIMEOUT_MS = 5000; +const INDEXED_DB_CLEAR_STORE_TIMEOUT_MS = 5000; +``` + +注入脚本通过闭包引用(executeScript 会序列化 func,常量需在 func 外部定义并 capture,或仍写在 func 内但从模块常量赋值)。 + +### 3.2 IndexedDB 逻辑拆分(长期) + +将 `clearObjectStores`、`waitForDeleteDatabase` 等抽到 `src/utils/indexedDbCleaner.ts` 的纯函数,注入层只做: + +```typescript +async () => clearAllIndexedDBs(INDEXED_DB_DELETE_TIMEOUT_MS); +``` + +便于单测,不依赖 `mockExecuteScriptEval` 间接执行注入函数。工作量大,建议单独 PR。 + +--- + +## 实施顺序 + +```mermaid +flowchart TD + A[1.1 transaction.oncomplete] --> B[1.2 delete settle 防竞态] + B --> C[2.4 补多 DB 测试] + C --> D[2.1 部分成功 count] + D --> E[2.2 统一 executeInTab] + E --> F[2.3 错误分隔符] + F --> G[3.x 可选重构] +``` + +| 阶段 | 预估工作量 | 风险 | +| ------- | ---------- | ---------------- | +| Phase 1 | 0.5~1 天 | 低,逻辑局部 | +| Phase 2 | 0.5~1 天 | 中,涉及类型扩展 | +| Phase 3 | 1~2 天 | 低,可延后 | + +--- + +## 验收标准 + +### A. 自动化(CI 必须通过) + +```bash +npm run test -- src/utils/__tests__/storageCleaner.test.ts +npm run typecheck +npm run lint +``` + +| 编号 | 标准 | +| ---- | ------------------------------------------------------------------------------ | +| A-1 | 全部单元测试通过,新增测试 ≥ 3(transaction 顺序、late onsuccess、多 DB 混合) | +| A-2 | `tsc --noEmit` 无错误;若扩展 `StorageCleanResult`,所有引用处类型正确 | +| A-3 | ESLint `--max-warnings=0` 通过 | + +--- + +### B. 功能行为 + +| 编号 | 场景 | 期望结果 | +| ---- | ---------------------------------------------------- | ----------------------------------------------------------------------------- | +| B-1 | 单 DB,`deleteDatabase` 成功 | `indexedDB: { success: true, count: 1 }`,`overallSuccess: true` | +| B-2 | 单 DB,`deleteDatabase` blocked,fallback clear 成功 | `success: true, count: 1`;transaction 在 `oncomplete` 后 `db.close` | +| B-3 | 单 DB,delete 超时 5s,fallback clear 成功 | 5s 内进入 fallback;不因 late `onsuccess` 重复计数 | +| B-4 | fallback 中某 store clear hang 5s | `success: false`,error 含 `dbName/storeName` | +| B-5 | 3 DB:2 成功 + 1 失败 | `success: false`,`count: 2`(Phase 2.1 后),error 含失败库名与部分成功提示 | +| B-6 | `executeScript` 注入失败 | `success: false`,**不得** `{ success: true, count: 0 }` | +| B-7 | localStorage 成功 + cookies 失败 | `overallSuccess: false`,`result.error` 为 `Cookies: ...`(换行分隔多项失败) | + +--- + +### C. 回归与 UI + +| 编号 | 标准 | +| ---- | --------------------------------------------------------------------------------------------------------- | +| C-1 | `formatCleaningResult` 成功路径不变 | +| C-2 | `CleaningResult` 失败时展示 `result.error`;含 `\n` 时多行可读(现有 `leading-relaxed break-all` 可接受) | +| C-3 | `reloadAfterClean=true` 且 `overallSuccess=false` 时不刷新页面(`useStorageCleaner` 现有逻辑) | +| C-4 | Cookie 清理:domain 前导 `.` 剥离逻辑不变 | + +--- + +### D. 手动验收(扩展环境) + +在 Chrome 加载 unpacked extension,选普通 HTTPS 页面: + +| 编号 | 步骤 | 期望 | +| ---- | ------------------------------------------------- | ----------------------------------------------------------------------- | +| D-1 | 页面写入 localStorage + IndexedDB,仅清 IndexedDB | 成功提示或明确错误;DevTools → Application → IndexedDB 数据为空或库已删 | +| D-2 | 打开 DevTools 保持 IndexedDB 面板,执行清理 | 若 blocked,显示中文提示;fallback 成功后数据不可见 | +| D-3 | 勾选「清理后刷新」且全部成功 | Toast「清理成功,即将刷新页面」,页面刷新 | +| D-4 | 部分失败 | 不刷新;结果区红色展示错误详情 | + +--- + +### E. 代码质量 + +| 编号 | 标准 | +| ---- | ------------------------------------------------------------------ | +| E-1 | 注入脚本内无重复 `settled` / timeout 逻辑(删除与 clear 各自封装) | +| E-2 | 错误文案仍为中文,含库名/store 名 | +| E-3 | 无 `any`(测试文件除外) | +| E-4 | Phase 1 合并后,P1 项在 PR 描述中标注「已修复」并附测试名 | + +--- + +## PR 检查清单 + +```markdown +## 修复内容 + +- [ ] P1: transaction.oncomplete 后再 db.close +- [ ] P1: deleteDatabase settle 防竞态 +- [ ] P2: 部分成功保留 count(可选) +- [ ] P2: 多 DB 混合测试 +- [ ] P2: 错误信息 `\n` 分隔(可选) + +## 验收 + +- [ ] npm run test / typecheck / lint 通过 +- [ ] 新增测试覆盖 B-2、B-3、B-5 +- [ ] 手动 D-1 ~ D-4 至少测 D-1、D-3 +``` + +--- + +## 风险与边界说明 + +1. **fallback 清空 ≠ 删除库**:blocked 时只清 object store,库结构仍在;成功 `count` 表示「有效清理动作完成」,需在 UI/文档中说明(可选文案:「数据已清空,数据库结构可能仍存在」)。 +2. **timeout 延迟 fallback**:100~200ms 为经验值,无法完全消除竞态,只能降低概率;完全消除需浏览器不支持 abort delete 的前提下接受 best-effort。 +3. **类型扩展**:`StorageCleanResult` 加可选 `count` 为向后兼容;消费方用 `'count' in result && result.count` 判断即可。 + +--- + +## 相关文件索引 + +| 文件 | 说明 | +| -------------------------------------------------------- | -------------------------------------------- | +| `src/utils/storageCleaner.ts` | 核心清理逻辑 | +| `src/utils/__tests__/storageCleaner.test.ts` | 单元测试 | +| `src/types/storage.d.ts` | `StorageCleanResult` / `CleaningResult` 类型 | +| `src/pages/StorageCleaner/constants.ts` | 选项标签与键名 | +| `src/pages/StorageCleaner/useStorageCleaner.ts` | 清理流程编排 | +| `src/pages/StorageCleaner/components/CleaningResult.tsx` | 结果展示 UI | diff --git a/src/components/__tests__/StorageCleanerConfirm.test.tsx b/src/components/__tests__/StorageCleanerConfirm.test.tsx index 4f52182..6cd2fbe 100644 --- a/src/components/__tests__/StorageCleanerConfirm.test.tsx +++ b/src/components/__tests__/StorageCleanerConfirm.test.tsx @@ -94,7 +94,7 @@ describe('StorageCleanerConfirm 组件', () => { renderComponent({ options: partialOptions }); expect(screen.getByText(/Local Storage/)).toBeInTheDocument(); - expect(screen.getByText(/站点存储/)).toBeInTheDocument(); + expect(screen.getByText(/IndexedDB/)).toBeInTheDocument(); expect(screen.queryByText(/Session Storage/)).not.toBeInTheDocument(); expect(screen.queryByText(/Cookies$/)).not.toBeInTheDocument(); }); diff --git a/src/pages/StorageCleaner/__tests__/index.test.tsx b/src/pages/StorageCleaner/__tests__/index.test.tsx index 4431570..373bb15 100644 --- a/src/pages/StorageCleaner/__tests__/index.test.tsx +++ b/src/pages/StorageCleaner/__tests__/index.test.tsx @@ -1,6 +1,9 @@ -import { render, screen } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { browser } from 'wxt/browser'; import Index from '../index'; +import { clearStorage, getCookieSize, getCurrentTab } from '@/utils/storageCleaner'; +import { toast } from 'sonner'; // Mock the chrome APIs vi.mock('@/utils/chromeStorage', () => ({ @@ -22,10 +25,82 @@ vi.mock('@/utils/storageCleaner', () => ({ formatCleaningResult: vi.fn().mockReturnValue('Cleaned successfully'), })); +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + }, +})); + describe('StorageCleaner 页面', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getCurrentTab).mockResolvedValue({ id: 1, url: 'https://example.com' } as any); + vi.mocked(clearStorage).mockResolvedValue({ overallSuccess: true }); + }); + it('应该渲染初始化加载状态', () => { - // storageCleaner:initializing 的中文文案为「正在读取站点数据...」 render(); - expect(screen.getByText(/正在读取站点数据/)).toBeInTheDocument(); + expect(screen.getByText(/正在读取数据/)).toBeInTheDocument(); + }); + + it('读取当前标签页失败时应显示错误提示', async () => { + vi.mocked(getCurrentTab).mockRejectedValueOnce(new Error('Tabs unavailable')); + + render(); + + expect(await screen.findByText('读取数据失败')).toBeInTheDocument(); + }); + + it('确认清理前如果当前标签页变为受限页面,不应执行清理', async () => { + render(); + + const cleanButton = await screen.findByRole('button', { name: /立即清理/ }); + fireEvent.click(cleanButton); + + vi.mocked(getCurrentTab).mockResolvedValueOnce({ + id: 1, + url: 'chrome://extensions', + } as any); + fireEvent.click(screen.getByRole('button', { name: /确认清理/ })); + + await waitFor(() => { + expect(clearStorage).not.toHaveBeenCalled(); + expect(toast.warning).toHaveBeenCalledWith('存储清理功能不支持此页面'); + }); + }); + + it('自动刷新后应等待标签页完成加载并重新读取信息后再允许再次清理', async () => { + const tabUpdatedListeners: Array<(tabId: number, changeInfo: { status?: string }) => void> = []; + (browser.tabs.onUpdated.addListener as any).mockImplementation((listener: any) => { + tabUpdatedListeners.push(listener); + }); + (browser.tabs.onUpdated.removeListener as any).mockImplementation((listener: any) => { + const index = tabUpdatedListeners.indexOf(listener); + if (index >= 0) tabUpdatedListeners.splice(index, 1); + }); + + render(); + + const cleanButton = await screen.findByRole('button', { name: /立即清理/ }); + fireEvent.click(cleanButton); + fireEvent.click(screen.getByRole('button', { name: /确认清理/ })); + + await waitFor(() => expect(browser.tabs.reload).toHaveBeenCalledWith(1)); + + const loadingButton = screen.getByRole('button', { name: /正在清理/ }); + expect(loadingButton).toBeDisabled(); + fireEvent.click(loadingButton); + expect(clearStorage).toHaveBeenCalledTimes(1); + + const reloadListener = tabUpdatedListeners.at(-1); + expect(reloadListener).toBeDefined(); + reloadListener?.(1, { status: 'complete' }); + + await waitFor(() => { + expect(getCookieSize).toHaveBeenCalledTimes(2); + expect(screen.getByRole('button', { name: /立即清理/ })).not.toBeDisabled(); + }); }); }); diff --git a/src/pages/StorageCleaner/components/StorageOptionsGrid.tsx b/src/pages/StorageCleaner/components/StorageOptionsGrid.tsx index 4905988..b8c23e5 100644 --- a/src/pages/StorageCleaner/components/StorageOptionsGrid.tsx +++ b/src/pages/StorageCleaner/components/StorageOptionsGrid.tsx @@ -5,6 +5,7 @@ import OptionItem from './OptionItem'; import { cn } from '@/lib/utils'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; +import { CLEAN_OPTION_KEYS } from '../constants'; interface StorageOptionsGridProps extends React.HTMLAttributes { options: StorageCleanerOptions; @@ -25,15 +26,6 @@ export default function StorageOptionsGrid({ className, ...props }: StorageOptionsGridProps) { - const optionKeys: (keyof StorageCleanerOptions)[] = [ - 'localStorage', - 'sessionStorage', - 'indexedDB', - 'cookies', - 'cacheStorage', - 'serviceWorkers', - ]; - const handleToggleAll = () => { onSelectAll(!allSelected); }; @@ -42,7 +34,7 @@ export default function StorageOptionsGrid({
- {optionKeys.map((key) => ( + {CLEAN_OPTION_KEYS.map((key) => ( = { localStorage: 'Local Storage', sessionStorage: 'Session Storage', - indexedDB: '站点存储', + indexedDB: 'IndexedDB', cookies: 'Cookies', cacheStorage: 'Cache Storage', serviceWorkers: 'Service Workers', diff --git a/src/pages/StorageCleaner/index.tsx b/src/pages/StorageCleaner/index.tsx index 5a85b2d..4557f2d 100644 --- a/src/pages/StorageCleaner/index.tsx +++ b/src/pages/StorageCleaner/index.tsx @@ -33,7 +33,7 @@ export default function Index() {
- {'正在读取站点数据...'} + {'正在读取数据...'}
); diff --git a/src/pages/StorageCleaner/useStorageCleaner.ts b/src/pages/StorageCleaner/useStorageCleaner.ts index 5d5bd43..437d1df 100644 --- a/src/pages/StorageCleaner/useStorageCleaner.ts +++ b/src/pages/StorageCleaner/useStorageCleaner.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { browser } from 'wxt/browser'; import { storageUtil } from '@/utils/chromeStorage'; import type { CleaningResult, @@ -32,6 +33,36 @@ const DEFAULT_PREFERENCES: StorageCleanerPreferences = { selectedTypes: DEFAULT_OPTIONS, }; +const RELOAD_COMPLETE_TIMEOUT_MS = 10_000; + +async function reloadTabAndWaitForComplete(tabId: number): Promise { + await new Promise((resolve, reject) => { + function cleanup() { + clearTimeout(timeoutId); + browser.tabs.onUpdated.removeListener(handleUpdated); + } + + function finish() { + cleanup(); + resolve(); + } + + function handleUpdated(updatedTabId: number, changeInfo: { status?: string }) { + if (updatedTabId === tabId && changeInfo.status === 'complete') { + finish(); + } + } + + const timeoutId = setTimeout(finish, RELOAD_COMPLETE_TIMEOUT_MS); + browser.tabs.onUpdated.addListener(handleUpdated); + + browser.tabs.reload(tabId).catch((err) => { + cleanup(); + reject(err); + }); + }); +} + export interface StorageSizeInfo { value: number; displayType: 'bytes' | 'count'; @@ -128,6 +159,11 @@ export function useStorageCleaner(): UseStorageCleanerReturn { cacheStorage: { value: cacheCount, displayType: 'count' }, serviceWorkers: { value: swCount, displayType: 'count' }, }); + } catch (err) { + console.error('Failed to load storage cleaner info:', err); + if (currentRequestId === requestIdRef.current) { + setError('读取数据失败'); + } } finally { if (currentRequestId === requestIdRef.current) { setIsInitializing(false); @@ -158,14 +194,14 @@ export function useStorageCleaner(): UseStorageCleanerReturn { } }; - chrome.tabs.onActivated.addListener(handleTabChange); - chrome.tabs.onUpdated.addListener(handleTabUpdated); - chrome.windows.onFocusChanged.addListener(handleTabChange); + browser.tabs.onActivated.addListener(handleTabChange); + browser.tabs.onUpdated.addListener(handleTabUpdated); + browser.windows.onFocusChanged.addListener(handleTabChange); return () => { - chrome.tabs.onActivated.removeListener(handleTabChange); - chrome.tabs.onUpdated.removeListener(handleTabUpdated); - chrome.windows.onFocusChanged.removeListener(handleTabChange); + browser.tabs.onActivated.removeListener(handleTabChange); + browser.tabs.onUpdated.removeListener(handleTabUpdated); + browser.windows.onFocusChanged.removeListener(handleTabChange); }; }, [debouncedLoadInfo]); @@ -174,9 +210,15 @@ export function useStorageCleaner(): UseStorageCleanerReturn { if (storageTimerRef.current) clearTimeout(storageTimerRef.current); storageTimerRef.current = setTimeout(async () => { - await storageUtil - .set('storageCleaner/preferences', { reloadAfterClean, selectedTypes: options }) - .catch(console.error); + try { + await storageUtil.set('storageCleaner/preferences', { + reloadAfterClean, + selectedTypes: options, + }); + } catch (err) { + console.error('Failed to save storage cleaner preferences:', err); + toast.warning('偏好保存失败,本次设置可能不会保留'); + } }, 500); }, [options, reloadAfterClean, isInitializing]); @@ -203,19 +245,27 @@ export function useStorageCleaner(): UseStorageCleanerReturn { if (loadingRef.current) return; const tab = await getCurrentTab(); - if (!tab || !tab.id || !tab.url) { + if (!tab || tab.id === undefined || !tab.url) { toast.warning('无法获取当前标签页'); return; } + if (isRestrictedUrl(tab.url)) { + toast.warning('存储清理功能不支持此页面'); + setShowConfirm(false); + return; + } + setLoading(true); + setShowConfirm(false); try { const cleaningResult = await clearStorage(tab.id, tab.url, options); setResult(cleaningResult); if (reloadAfterClean && cleaningResult.overallSuccess) { toast.success('清理成功,即将刷新页面'); - await chrome.tabs.reload(tab.id); + await reloadTabAndWaitForComplete(tab.id); + await loadInfo(); } else { await loadInfo(); } diff --git a/src/types/storage.d.ts b/src/types/storage.d.ts index c430178..bd1e2c3 100644 --- a/src/types/storage.d.ts +++ b/src/types/storage.d.ts @@ -183,6 +183,8 @@ export type StorageCleanResult = success: false; /** 错误信息 */ error: string; + /** 部分成功时的已清理数量(如 IndexedDB 多库场景) */ + count?: number; }; /** diff --git a/src/utils/__tests__/indexedDbCleaner.test.ts b/src/utils/__tests__/indexedDbCleaner.test.ts new file mode 100644 index 0000000..9d9e443 --- /dev/null +++ b/src/utils/__tests__/indexedDbCleaner.test.ts @@ -0,0 +1,489 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearAllIndexedDBs, + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS, + INDEXED_DB_DELETE_FALLBACK_DELAY_MS, + INDEXED_DB_DELETE_TIMEOUT_MS, +} from '@/utils/indexedDbCleaner'; + +type DeleteDatabaseBehavior = 'success' | 'blocked' | 'timeout' | 'error'; +type ClearStoreBehavior = 'success' | 'hang'; + +function createDeleteDatabaseMock(behavior: DeleteDatabaseBehavior) { + return vi.fn(() => { + const request = {} as IDBOpenDBRequest; + if (behavior === 'timeout') { + return request; + } + queueMicrotask(() => { + if (behavior === 'blocked') { + request.onblocked?.({} as IDBVersionChangeEvent); + } else if (behavior === 'success') { + request.onsuccess?.({} as Event); + } else if (behavior === 'error') { + request.onerror?.({} as Event); + } + }); + return request; + }); +} + +function createDeleteDatabaseMockWithLateSuccess(lateAfterMs: number) { + return vi.fn(() => { + const request = {} as IDBOpenDBRequest; + setTimeout(() => { + request.onsuccess?.({} as Event); + }, lateAfterMs); + return request; + }); +} + +function createOpenMock(options: { + storeNames: string[]; + onClearStore?: () => void; + onTransactionComplete?: () => void; + onDbClose?: () => void; + clearStoreBehavior?: ClearStoreBehavior; + deferTransactionComplete?: boolean; + syncTransactionComplete?: boolean; + hangOpen?: boolean; +}) { + const { + storeNames, + onClearStore, + onTransactionComplete, + onDbClose, + clearStoreBehavior = 'success', + deferTransactionComplete = false, + syncTransactionComplete = false, + hangOpen = false, + } = options; + + return vi.fn(() => { + const request = {} as IDBOpenDBRequest; + if (hangOpen) { + return request; + } + const db = { + objectStoreNames: storeNames, + transaction: vi.fn(() => { + const tx = { + oncomplete: null as ((event: Event) => void) | null, + onerror: null as ((event: Event) => void) | null, + onabort: null as ((event: Event) => void) | null, + abort: vi.fn(), + objectStore: vi.fn(() => ({ + clear: () => { + const clearRequest = {} as IDBRequest; + onClearStore?.(); + if (clearStoreBehavior === 'success') { + queueMicrotask(() => { + clearRequest.onsuccess?.({} as Event); + if (syncTransactionComplete) { + onTransactionComplete?.(); + tx.oncomplete?.({} as Event); + return; + } + if (deferTransactionComplete) { + return; + } + setTimeout(() => { + onTransactionComplete?.(); + tx.oncomplete?.({} as Event); + }, 0); + }); + } + return clearRequest; + }, + })), + }; + return tx; + }), + close: vi.fn(() => { + onDbClose?.(); + }), + }; + queueMicrotask(() => { + Object.defineProperty(request, 'result', { value: db }); + request.onsuccess?.({} as Event); + }); + return request; + }); +} + +type DeleteBehaviorConfig = DeleteDatabaseBehavior | Record; + +function createIndexedDBMock(options: { + databases: Array<{ name: string }>; + deleteBehavior: DeleteBehaviorConfig; + open?: ReturnType; +}) { + const resolveDeleteBehavior = (dbName: string): DeleteDatabaseBehavior => { + if (typeof options.deleteBehavior === 'string') { + return options.deleteBehavior; + } + return options.deleteBehavior[dbName] ?? 'success'; + }; + + return { + databases: vi.fn().mockResolvedValue(options.databases), + deleteDatabase: vi.fn((dbName: string) => + createDeleteDatabaseMock(resolveDeleteBehavior(dbName))(), + ), + ...(options.open ? { open: options.open } : {}), + }; +} + +async function withIndexedDBMock(indexedDBMock: object, run: () => Promise): Promise { + const originalIndexedDB = globalThis.indexedDB; + Object.defineProperty(globalThis, 'indexedDB', { configurable: true, value: indexedDBMock }); + try { + return await run(); + } finally { + Object.defineProperty(globalThis, 'indexedDB', { + configurable: true, + value: originalIndexedDB, + }); + } +} + +describe('indexedDbCleaner', () => { + const runClear = () => + clearAllIndexedDBs( + INDEXED_DB_DELETE_TIMEOUT_MS, + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS, + INDEXED_DB_DELETE_FALLBACK_DELAY_MS, + ); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should delete IndexedDB successfully when deleteDatabase completes', async () => { + const indexedDBMock = createIndexedDBMock({ + databases: [{ name: 'app-db' }], + deleteBehavior: 'success', + }); + + const result = await withIndexedDBMock(indexedDBMock, () => + clearAllIndexedDBs( + INDEXED_DB_DELETE_TIMEOUT_MS, + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS, + INDEXED_DB_DELETE_FALLBACK_DELAY_MS, + ), + ); + + expect(result).toEqual({ count: 1, errors: [] }); + expect(indexedDBMock.deleteDatabase).toHaveBeenCalledWith('app-db'); + }); + + it('should report IndexedDB blocked deletions with a user-facing hint', async () => { + const indexedDBMock = createIndexedDBMock({ + databases: [{ name: 'blocked-db' }], + deleteBehavior: 'blocked', + }); + + const result = await withIndexedDBMock(indexedDBMock, () => + clearAllIndexedDBs( + INDEXED_DB_DELETE_TIMEOUT_MS, + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS, + INDEXED_DB_DELETE_FALLBACK_DELAY_MS, + ), + ); + + expect(result).toEqual({ + count: 0, + errors: ['页面仍占用 IndexedDB(blocked-db),请刷新后重试或关闭占用该页面的连接'], + }); + expect(indexedDBMock.deleteDatabase).toHaveBeenCalledWith('blocked-db'); + }); + + it('should clear object stores when IndexedDB deletion is blocked', async () => { + const clearStore = vi.fn(); + const indexedDBMock = createIndexedDBMock({ + databases: [{ name: 'ImageCacheDB' }], + deleteBehavior: 'blocked', + open: createOpenMock({ storeNames: ['images'], onClearStore: clearStore }), + }); + + const result = await withIndexedDBMock(indexedDBMock, runClear); + + expect(result).toEqual({ count: 1, errors: [] }); + expect(clearStore).toHaveBeenCalledTimes(1); + expect(indexedDBMock.deleteDatabase).not.toHaveBeenCalled(); + }); + + it('should clear object stores when IndexedDB deletion times out', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const clearStore = vi.fn(); + const openMock = createOpenMock({ storeNames: ['images'], onClearStore: clearStore }); + const hangOpenRequest = {} as IDBOpenDBRequest; + openMock.mockImplementationOnce(() => hangOpenRequest); + const indexedDBMock = createIndexedDBMock({ + databases: [{ name: 'ImageCacheDB' }], + deleteBehavior: 'timeout', + open: openMock, + }); + + const result = await withIndexedDBMock(indexedDBMock, async () => { + const resultPromise = runClear(); + await vi.advanceTimersByTimeAsync( + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS + + INDEXED_DB_DELETE_TIMEOUT_MS + + INDEXED_DB_DELETE_FALLBACK_DELAY_MS, + ); + await vi.runOnlyPendingTimersAsync(); + return resultPromise; + }); + + expect(result).toEqual({ count: 1, errors: [] }); + expect(clearStore).toHaveBeenCalledTimes(1); + }); + + it('should wait for transaction complete before closing db', async () => { + const events: string[] = []; + const indexedDBMock = createIndexedDBMock({ + databases: [{ name: 'ImageCacheDB' }], + deleteBehavior: 'blocked', + open: createOpenMock({ + storeNames: ['images'], + onTransactionComplete: () => events.push('transaction-complete'), + onDbClose: () => events.push('db-close'), + }), + }); + + await withIndexedDBMock(indexedDBMock, runClear); + + expect(events).toEqual(['transaction-complete', 'db-close']); + }); + + it('should not hang when transaction completes before clear promises settle', async () => { + const indexedDBMock = createIndexedDBMock({ + databases: [{ name: 'ImageCacheDB' }], + deleteBehavior: 'blocked', + open: createOpenMock({ + storeNames: ['images'], + syncTransactionComplete: true, + }), + }); + + const result = await withIndexedDBMock(indexedDBMock, runClear); + + expect(result).toEqual({ count: 1, errors: [] }); + }); + + it('should timeout when opening IndexedDB for fallback never completes', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const indexedDBMock = createIndexedDBMock({ + databases: [{ name: 'ImageCacheDB' }], + deleteBehavior: 'blocked', + open: createOpenMock({ storeNames: ['images'], hangOpen: true }), + }); + + const resultPromise = withIndexedDBMock(indexedDBMock, runClear); + await vi.advanceTimersByTimeAsync( + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS + + INDEXED_DB_DELETE_FALLBACK_DELAY_MS + + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS, + ); + const result = await resultPromise; + + expect(result).toEqual({ + count: 0, + errors: ['无法打开 IndexedDB(ImageCacheDB)进行清空,请刷新后重试'], + }); + }); + + it('should ignore late onsuccess after delete timeout', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const clearStore = vi.fn(); + const openMock = createOpenMock({ storeNames: ['images'], onClearStore: clearStore }); + const hangOpenRequest = {} as IDBOpenDBRequest; + openMock.mockImplementationOnce(() => hangOpenRequest); + const indexedDBMock = { + databases: vi.fn().mockResolvedValue([{ name: 'ImageCacheDB' }]), + deleteDatabase: createDeleteDatabaseMockWithLateSuccess(INDEXED_DB_DELETE_TIMEOUT_MS + 1000), + open: openMock, + }; + + const result = await withIndexedDBMock(indexedDBMock, async () => { + const resultPromise = runClear(); + await vi.advanceTimersByTimeAsync( + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS + + INDEXED_DB_DELETE_TIMEOUT_MS + + INDEXED_DB_DELETE_FALLBACK_DELAY_MS, + ); + await vi.runOnlyPendingTimersAsync(); + return resultPromise; + }); + + expect(result).toEqual({ count: 1, errors: [] }); + expect(clearStore).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1000); + expect(result).toEqual({ count: 1, errors: [] }); + }); + + it('should report the store name when fallback clearing times out', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const openMock = createOpenMock({ storeNames: ['images'], clearStoreBehavior: 'hang' }); + const hangOpenRequest = {} as IDBOpenDBRequest; + openMock.mockImplementationOnce(() => hangOpenRequest); + const indexedDBMock = createIndexedDBMock({ + databases: [{ name: 'ImageCacheDB' }], + deleteBehavior: 'blocked', + open: openMock, + }); + + const result = await withIndexedDBMock(indexedDBMock, async () => { + const resultPromise = runClear(); + await vi.advanceTimersByTimeAsync( + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS + + INDEXED_DB_DELETE_FALLBACK_DELAY_MS + + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS, + ); + return resultPromise; + }); + + expect(result).toEqual({ + count: 0, + errors: ['清空 IndexedDB 超时(ImageCacheDB/images),请刷新后重试'], + }); + }); + + it('should delete all IndexedDB databases when every delete succeeds', async () => { + const indexedDBMock = createIndexedDBMock({ + databases: [{ name: 'db-a' }, { name: 'db-b' }, { name: 'db-c' }], + deleteBehavior: 'success', + }); + + const result = await withIndexedDBMock(indexedDBMock, () => + clearAllIndexedDBs( + INDEXED_DB_DELETE_TIMEOUT_MS, + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS, + INDEXED_DB_DELETE_FALLBACK_DELAY_MS, + ), + ); + + expect(result).toEqual({ count: 3, errors: [] }); + expect(indexedDBMock.deleteDatabase).toHaveBeenCalledTimes(3); + }); + + it('should succeed when some deletions fallback to clear object stores', async () => { + const clearStore = vi.fn(); + const indexedDBMock = createIndexedDBMock({ + databases: [{ name: 'db-a' }, { name: 'db-b' }, { name: 'db-c' }], + deleteBehavior: { 'db-a': 'success', 'db-b': 'success', 'db-c': 'blocked' }, + open: createOpenMock({ storeNames: ['data'], onClearStore: clearStore }), + }); + + const result = await withIndexedDBMock(indexedDBMock, () => + clearAllIndexedDBs( + INDEXED_DB_DELETE_TIMEOUT_MS, + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS, + INDEXED_DB_DELETE_FALLBACK_DELAY_MS, + ), + ); + + expect(result).toEqual({ count: 3, errors: [] }); + expect(clearStore).toHaveBeenCalledTimes(3); + expect(indexedDBMock.deleteDatabase).not.toHaveBeenCalled(); + }); + + it('should preserve partial count when some IndexedDB databases fail', async () => { + const indexedDBMock = createIndexedDBMock({ + databases: [{ name: 'db-a' }, { name: 'db-b' }, { name: 'db-c' }], + deleteBehavior: { 'db-a': 'success', 'db-b': 'success', 'db-c': 'error' }, + }); + + const result = await withIndexedDBMock(indexedDBMock, () => + clearAllIndexedDBs( + INDEXED_DB_DELETE_TIMEOUT_MS, + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS, + INDEXED_DB_DELETE_FALLBACK_DELAY_MS, + ), + ); + + expect(result).toEqual({ + count: 2, + errors: ['删除 IndexedDB 失败(db-c),请刷新后重试'], + }); + }); + + it('should preserve partial count when blocked fallback fails for one database', async () => { + const indexedDBMock = createIndexedDBMock({ + databases: [{ name: 'db-a' }, { name: 'db-b' }, { name: 'blocked-db' }], + deleteBehavior: { 'db-a': 'success', 'db-b': 'success', 'blocked-db': 'blocked' }, + }); + + const result = await withIndexedDBMock(indexedDBMock, () => + clearAllIndexedDBs( + INDEXED_DB_DELETE_TIMEOUT_MS, + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS, + INDEXED_DB_DELETE_FALLBACK_DELAY_MS, + ), + ); + + expect(result).toEqual({ + count: 2, + errors: ['页面仍占用 IndexedDB(blocked-db),请刷新后重试或关闭占用该页面的连接'], + }); + }); + + it('should clear ImageCacheDB on consecutive attempts without calling delete first', async () => { + const clearStore = vi.fn(); + const openMock = createOpenMock({ storeNames: ['images'], onClearStore: clearStore }); + const indexedDBMock = createIndexedDBMock({ + databases: [{ name: 'ImageCacheDB' }], + deleteBehavior: 'blocked', + open: openMock, + }); + + await withIndexedDBMock(indexedDBMock, runClear); + const secondResult = await withIndexedDBMock(indexedDBMock, runClear); + + expect(secondResult).toEqual({ count: 1, errors: [] }); + expect(clearStore).toHaveBeenCalledTimes(2); + expect(indexedDBMock.deleteDatabase).not.toHaveBeenCalled(); + }); + + it('should run when deserialized into page context with explicit timeout args', async () => { + const indexedDBMock = createIndexedDBMock({ + databases: [], + deleteBehavior: 'success', + }); + + const result = await withIndexedDBMock(indexedDBMock, async () => { + const injected = (0, eval)(`(${clearAllIndexedDBs.toString()})`) as typeof clearAllIndexedDBs; + return injected( + INDEXED_DB_DELETE_TIMEOUT_MS, + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS, + INDEXED_DB_DELETE_FALLBACK_DELAY_MS, + ); + }); + + expect(result).toEqual({ count: 0, errors: [] }); + }); + + it('should return count 0 when no IndexedDB databases exist', async () => { + const indexedDBMock = createIndexedDBMock({ + databases: [], + deleteBehavior: 'success', + }); + + const result = await withIndexedDBMock(indexedDBMock, () => + clearAllIndexedDBs( + INDEXED_DB_DELETE_TIMEOUT_MS, + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS, + INDEXED_DB_DELETE_FALLBACK_DELAY_MS, + ), + ); + + expect(result).toEqual({ count: 0, errors: [] }); + expect(indexedDBMock.deleteDatabase).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/__tests__/storageCleaner.test.ts b/src/utils/__tests__/storageCleaner.test.ts index a4e9787..cc9d517 100644 --- a/src/utils/__tests__/storageCleaner.test.ts +++ b/src/utils/__tests__/storageCleaner.test.ts @@ -1,39 +1,61 @@ -import { describe, expect, it } from 'vitest'; -import { clearCookies } from '@/utils/storageCleaner'; -import { formatBytes } from '@/utils/format'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearAllIndexedDBs, + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS, + INDEXED_DB_DELETE_FALLBACK_DELAY_MS, + INDEXED_DB_DELETE_TIMEOUT_MS, +} from '@/utils/indexedDbCleaner'; +import { clearCookies, clearStorage } from '@/utils/storageCleaner'; +import type { StorageCleanerOptions } from '@/types/storage'; + +const indexedDBClearOptions: StorageCleanerOptions = { + localStorage: false, + sessionStorage: false, + indexedDB: true, + cookies: false, + cacheStorage: false, + serviceWorkers: false, +}; + +function mockExecuteScriptEval() { + (chrome.scripting.executeScript as any).mockImplementationOnce( + async ({ func, args }: { func: (...a: unknown[]) => unknown; args?: unknown[] }) => { + if (func === clearAllIndexedDBs) { + const deleteTimeoutMs = (args?.[0] as number | undefined) ?? INDEXED_DB_DELETE_TIMEOUT_MS; + const clearStoreTimeoutMs = + (args?.[1] as number | undefined) ?? INDEXED_DB_CLEAR_STORE_TIMEOUT_MS; + const fallbackDelayMs = + (args?.[2] as number | undefined) ?? INDEXED_DB_DELETE_FALLBACK_DELAY_MS; + return [ + { + result: await clearAllIndexedDBs(deleteTimeoutMs, clearStoreTimeoutMs, fallbackDelayMs), + }, + ]; + } + const isolatedFunc = (0, eval)(`(${func.toString()})`) as ( + ...a: unknown[] + ) => Promise; + return [{ result: await isolatedFunc(...(args ?? [])) }]; + }, + ); +} + +function mockIndexedDBForEmptyDatabases() { + Object.defineProperty(globalThis, 'indexedDB', { + configurable: true, + value: { + databases: vi.fn().mockResolvedValue([]), + }, + }); +} describe('storageCleaner utils', () => { - describe('formatBytes', () => { - it('should return "0 B" for 0 bytes', () => { - expect(formatBytes(0)).toBe('0 B'); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - it('should format bytes correctly', () => { - expect(formatBytes(500)).toBe('500 B'); - }); - - it('should format kilobytes correctly', () => { - expect(formatBytes(1024)).toBe('1.0 KB'); - expect(formatBytes(1536)).toBe('1.5 KB'); - expect(formatBytes(2048)).toBe('2.0 KB'); - }); - - it('should format megabytes correctly', () => { - 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 correctly', () => { - expect(formatBytes(1073741824)).toBe('1.00 GB'); - expect(formatBytes(2147483648)).toBe('2.00 GB'); - }); - - it('should handle edge cases', () => { - expect(formatBytes(1)).toBe('1 B'); - expect(formatBytes(1023)).toBe('1023 B'); - expect(formatBytes(1025)).toBe('1.0 KB'); - }); + afterEach(() => { + vi.useRealTimers(); }); describe('clearCookies', () => { @@ -85,4 +107,107 @@ describe('storageCleaner utils', () => { expect(result).toEqual({ success: false, error: 'Error: Permission denied' }); }); }); + + describe('clearStorage', () => { + it('should report script injection failures instead of treating fallback values as success', async () => { + (chrome.scripting.executeScript as any).mockRejectedValueOnce( + new Error('Cannot access this page'), + ); + + const result = await clearStorage(1, 'https://example.com', { + localStorage: true, + sessionStorage: false, + indexedDB: false, + cookies: false, + cacheStorage: false, + serviceWorkers: false, + }); + + expect(result.overallSuccess).toBe(false); + expect(result.localStorage).toEqual({ + success: false, + error: 'Error: Cannot access this page', + }); + }); + + it('should wire IndexedDB cleanup through executeScript with clearAllIndexedDBs', async () => { + mockIndexedDBForEmptyDatabases(); + mockExecuteScriptEval(); + + const result = await clearStorage(1, 'https://example.com', indexedDBClearOptions); + + expect(chrome.scripting.executeScript).toHaveBeenCalledWith( + expect.objectContaining({ + func: clearAllIndexedDBs, + args: [ + INDEXED_DB_DELETE_TIMEOUT_MS, + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS, + INDEXED_DB_DELETE_FALLBACK_DELAY_MS, + ], + }), + ); + expect(result.overallSuccess).toBe(true); + expect(result.indexedDB).toEqual({ success: true, count: 0 }); + }); + + it('should aggregate selected storage failures into overallSuccess', async () => { + (chrome.scripting.executeScript as any).mockResolvedValueOnce([{ result: { count: 1 } }]); + (chrome.cookies.getAll as any).mockRejectedValueOnce(new Error('Cookie denied')); + + const result = await clearStorage(1, 'https://example.com', { + localStorage: true, + sessionStorage: false, + indexedDB: false, + cookies: true, + cacheStorage: false, + serviceWorkers: false, + }); + + expect(result.overallSuccess).toBe(false); + expect(result.localStorage).toEqual({ success: true, count: 1 }); + expect(result.cookies).toEqual({ success: false, error: 'Error: Cookie denied' }); + expect(result.error).toBe('Cookies: Error: Cookie denied'); + }); + + it('should join multiple storage failure messages in result.error', async () => { + (chrome.scripting.executeScript as any).mockRejectedValueOnce(new Error('Script denied')); + (chrome.cookies.getAll as any).mockRejectedValueOnce(new Error('Cookie denied')); + + const result = await clearStorage(1, 'https://example.com', { + localStorage: true, + sessionStorage: false, + indexedDB: false, + cookies: true, + cacheStorage: false, + serviceWorkers: false, + }); + + expect(result.overallSuccess).toBe(false); + expect(result.localStorage).toEqual({ success: false, error: 'Error: Script denied' }); + expect(result.cookies).toEqual({ success: false, error: 'Error: Cookie denied' }); + expect(result.error).toBe( + 'Local Storage: Error: Script denied\nCookies: Error: Cookie denied', + ); + }); + + it('should preserve partial IndexedDB count when runCleanScript receives errors', async () => { + (chrome.scripting.executeScript as any).mockImplementationOnce(async () => [ + { + result: { + count: 2, + errors: ['删除 IndexedDB 失败(db-c),请刷新后重试'], + }, + }, + ]); + + const result = await clearStorage(1, 'https://example.com', indexedDBClearOptions); + + expect(result.overallSuccess).toBe(false); + expect(result.indexedDB).toEqual({ + success: false, + count: 2, + error: '(已成功清理 2 个数据库,但部分失败)\n删除 IndexedDB 失败(db-c),请刷新后重试', + }); + }); + }); }); diff --git a/src/utils/indexedDbCleaner.ts b/src/utils/indexedDbCleaner.ts new file mode 100644 index 0000000..f101e43 --- /dev/null +++ b/src/utils/indexedDbCleaner.ts @@ -0,0 +1,226 @@ +export const INDEXED_DB_DELETE_TIMEOUT_MS = 5000; +export const INDEXED_DB_CLEAR_STORE_TIMEOUT_MS = 5000; +export const INDEXED_DB_DELETE_FALLBACK_DELAY_MS = 200; + +export interface IndexedDBCleanResult { + count: number; + errors?: string[]; +} + +/** + * 在页面上下文中清理当前 origin 的全部 IndexedDB。 + * 设计为可传入 executeScript({ func }) 的自包含函数。 + * + * 参数必须由调用方显式传入(不可使用模块常量作默认参数), + * 否则函数序列化到页面后缺省参数求值会 ReferenceError。 + */ +export async function clearAllIndexedDBs( + deleteTimeoutMs: number, + clearStoreTimeoutMs: number, + fallbackDelayMs: number, +): Promise { + const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + const waitForTransaction = (tx: IDBTransaction, timeoutMs: number): Promise => + new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error('Transaction timeout')); + }, timeoutMs); + + tx.oncomplete = () => { + clearTimeout(timeoutId); + resolve(); + }; + tx.onerror = () => { + clearTimeout(timeoutId); + reject(tx.error ?? new Error('Transaction failed')); + }; + tx.onabort = () => { + clearTimeout(timeoutId); + reject(tx.error ?? new Error('Transaction aborted')); + }; + }); + + const openDatabase = (dbName: string, timeoutMs: number): Promise => + new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error('Open timeout')); + }, timeoutMs); + + const openReq = indexedDB.open(dbName); + openReq.onerror = () => { + clearTimeout(timeoutId); + reject(openReq.error ?? new Error('Open failed')); + }; + openReq.onsuccess = () => { + clearTimeout(timeoutId); + resolve(openReq.result); + }; + }); + + const waitForDeleteDatabase = (dbName: string, timeoutMs: number) => + new Promise<'deleted' | 'blocked' | 'timeout' | 'error'>((resolve) => { + let settled = false; + const settle = (status: 'deleted' | 'blocked' | 'timeout' | 'error') => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + resolve(status); + }; + + const deleteReq = indexedDB.deleteDatabase(dbName); + const timeoutId = setTimeout(() => { + console.warn('IndexedDB delete timeout:', dbName); + settle('timeout'); + }, timeoutMs); + + deleteReq.onblocked = () => { + console.warn('IndexedDB delete blocked:', dbName); + settle('blocked'); + }; + deleteReq.onsuccess = () => settle('deleted'); + deleteReq.onerror = () => settle('error'); + }); + + const formatDeleteError = (dbName: string, status: 'blocked' | 'timeout' | 'error'): string => { + if (status === 'blocked') { + return `页面仍占用 IndexedDB(${dbName}),请刷新后重试或关闭占用该页面的连接`; + } + + if (status === 'timeout') { + return `删除 IndexedDB 超时(${dbName}),请刷新后重试`; + } + + return `删除 IndexedDB 失败(${dbName}),请刷新后重试`; + }; + + const clearObjectStores = async ( + dbName: string, + ): Promise<{ success: boolean; errors: string[] }> => { + if (typeof indexedDB.open !== 'function') { + return { success: false, errors: [] }; + } + + const clearStore = (store: IDBObjectStore, storeName: string): Promise => + new Promise((resolve) => { + const timeoutId = setTimeout(() => { + resolve(`清空 IndexedDB 超时(${dbName}/${storeName}),请刷新后重试`); + }, clearStoreTimeoutMs); + + const clearReq = store.clear(); + clearReq.onsuccess = () => { + clearTimeout(timeoutId); + resolve(null); + }; + clearReq.onerror = () => { + clearTimeout(timeoutId); + resolve(`清空 IndexedDB 失败(${dbName}/${storeName}),请刷新后重试`); + }; + }); + + let db: IDBDatabase | undefined; + try { + db = await openDatabase(dbName, clearStoreTimeoutMs); + } catch { + return { + success: false, + errors: [`无法打开 IndexedDB(${dbName})进行清空,请刷新后重试`], + }; + } + + try { + const storeNames = Array.from(db.objectStoreNames); + if (storeNames.length === 0) { + return { success: true, errors: [] }; + } + + const transaction = db.transaction(storeNames, 'readwrite'); + // 必须在 clear 请求完成前注册 oncomplete,否则事务可能已结束导致永久挂起 + const transactionDone = waitForTransaction(transaction, clearStoreTimeoutMs); + void transactionDone.catch(() => undefined); + const errors = ( + await Promise.all( + storeNames.map((storeName) => clearStore(transaction.objectStore(storeName), storeName)), + ) + ).filter((error): error is string => Boolean(error)); + + if (errors.length > 0) { + try { + transaction.abort(); + } catch { + // ignore abort failures on already-finished transactions + } + return { success: false, errors }; + } + + try { + await transactionDone; + } catch { + return { + success: false, + errors: [`清空 IndexedDB 失败(${dbName}),请刷新后重试`], + }; + } + + return { success: true, errors: [] }; + } catch { + return { + success: false, + errors: [`清空 IndexedDB 失败(${dbName}),请刷新后重试`], + }; + } finally { + db.close(); + } + }; + + try { + if (typeof indexedDB.databases !== 'function') { + return { count: 0 }; + } + + const databases = await indexedDB.databases(); + let count = 0; + const errors: string[] = []; + for (const db of databases) { + if (!db.name) continue; + const dbName = db.name; + + // 先清空 object store,再尝试 delete。若先 delete 且 blocked, + // delete 请求仍 pending 时再 open 同库容易超时(如 Bing ImageCacheDB 连续清理)。 + const clearResult = await clearObjectStores(dbName); + if (clearResult.success) { + count++; + continue; + } + + const status = await waitForDeleteDatabase(dbName, deleteTimeoutMs); + if (status === 'deleted') { + count++; + continue; + } + + if (status === 'blocked' || status === 'timeout') { + await delay(fallbackDelayMs); + const retryClear = await clearObjectStores(dbName); + if (retryClear.success) { + count++; + } else if (retryClear.errors.length > 0) { + errors.push(...retryClear.errors); + } else { + errors.push(formatDeleteError(dbName, status)); + } + continue; + } + + if (clearResult.errors.length > 0) { + errors.push(...clearResult.errors); + } else { + errors.push(formatDeleteError(dbName, status)); + } + } + + return { count, errors }; + } catch (error) { + return { count: 0, errors: [`读取或清理 IndexedDB 失败: ${String(error)}`] }; + } +} diff --git a/src/utils/storageCleaner.ts b/src/utils/storageCleaner.ts index 9c23099..0a23b65 100644 --- a/src/utils/storageCleaner.ts +++ b/src/utils/storageCleaner.ts @@ -1,12 +1,22 @@ import type { CleaningResult, StorageCleanerOptions, StorageCleanResult } from '@/types/storage'; import { CLEAN_OPTION_KEYS, OPTION_LABELS } from '@/pages/StorageCleaner/constants'; +import { + clearAllIndexedDBs, + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS, + INDEXED_DB_DELETE_FALLBACK_DELAY_MS, + INDEXED_DB_DELETE_TIMEOUT_MS, + type IndexedDBCleanResult, +} from '@/utils/indexedDbCleaner'; +import { browser } from 'wxt/browser'; + +type CleanScriptResult = IndexedDBCleanResult; 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({ + const [tab] = await browser.tabs.query({ active: true, lastFocusedWindow: true, }); @@ -16,7 +26,7 @@ export async function getCurrentTab() { } // Fallback for cases where lastFocusedWindow might not work as expected (e.g. certain sidepanel scenarios) - const [fallbackTab] = await chrome.tabs.query({ + const [fallbackTab] = await browser.tabs.query({ active: true, currentWindow: true, }); @@ -26,7 +36,7 @@ export async function getCurrentTab() { export async function getCookieSize(url: string): Promise { try { - const cookies = await chrome.cookies.getAll({ url }); + const cookies = await browser.cookies.getAll({ url }); const encoder = new TextEncoder(); // 估算:名称 + 值 + 域名 + 路径 的 UTF-8 字节数 return cookies.reduce( @@ -44,29 +54,49 @@ export async function getCookieSize(url: string): Promise { } } -/** - * 通用辅助:在指定标签页中执行脚本并返回结果 - * - * @param tabId 标签页 ID - * @param func 在页面上下文中执行的函数 - * @param errorLabel 错误日志前缀 - * @param fallback 执行失败时的回退值 - */ -async function runScript( +type ExecuteInTabOptions = + | { errorLabel: string; mode: 'fallback'; fallback: T } + | { errorLabel: string; mode: 'throw' }; + +async function executeInTab( tabId: number, - func: () => T | Promise, - errorLabel: string, - fallback: T, + func: (...args: A) => T | Promise, + options: ExecuteInTabOptions, + args?: A, ): Promise { try { - const [result] = await chrome.scripting.executeScript({ target: { tabId }, func }); - return (result?.result as T) ?? fallback; + const [result] = await browser.scripting.executeScript({ + target: { tabId }, + func, + ...(args ? { args } : {}), + }); + const value = result?.result as T | undefined; + if (value !== undefined && value !== null) { + return value; + } + if (options.mode === 'fallback') { + return options.fallback; + } + return undefined as T; } catch (error) { - console.error(`Failed to ${errorLabel}:`, error); - return fallback; + console.error(`Failed to ${options.errorLabel}:`, error); + if (options.mode === 'throw') { + throw error; + } + return options.fallback; } } +async function runScript( + tabId: number, + func: (...args: A) => T | Promise, + errorLabel: string, + fallback: T, + args?: A, +): Promise { + return executeInTab(tabId, func, { errorLabel, mode: 'fallback', fallback }, args); +} + export async function getLocalStorageSize(tabId: number): Promise { return runScript( tabId, @@ -164,12 +194,12 @@ export async function getServiceWorkerCount(tabId: number): Promise { export async function clearCookies(url: string): Promise { try { - const cookies = await chrome.cookies.getAll({ url }); + const cookies = await browser.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({ + await browser.cookies.remove({ url: cookieUrl, name: cookie.name, storeId: cookie.storeId, @@ -188,16 +218,33 @@ export async function clearCookies(url: string): Promise { * @param func 在页面上下文中执行的清理函数 * @param errorLabel 错误日志前缀 */ -async function runCleanScript( +async function runCleanScript( tabId: number, - func: () => { count: number } | Promise<{ count: number }>, + func: (...args: A) => CleanScriptResult | Promise, errorLabel: string, + args?: A, ): Promise { - const raw = await runScript(tabId, func, errorLabel, { count: 0 }); - if (raw && typeof raw === 'object' && 'count' in raw) { + try { + const raw = await executeInTab(tabId, func, { errorLabel, mode: 'throw' }, args); + if (!raw || typeof raw !== 'object' || !('count' in raw)) { + return { success: false, error: 'No result returned' }; + } + + if (raw.errors?.length) { + const errorMsg = raw.errors.join('\n'); + const partialHint = raw.count > 0 ? `(已成功清理 ${raw.count} 个数据库,但部分失败)\n` : ''; + return { + success: false, + error: partialHint + errorMsg, + ...(raw.count > 0 ? { count: raw.count } : {}), + }; + } + return { success: true, count: raw.count }; + } catch (error) { + console.error(`Failed to ${errorLabel}:`, error); + return { success: false, error: String(error) }; } - return { success: false, error: 'No result returned' }; } async function injectClearLocalStorage(tabId: number): Promise { @@ -225,47 +272,11 @@ async function injectClearSessionStorage(tabId: number): Promise { - return runCleanScript( - tabId, - async () => { - if (typeof indexedDB.databases !== 'function') { - return { count: 0 }; - } - const databases = await indexedDB.databases(); - let count = 0; - for (const db of databases) { - if (!db.name) continue; - const dbName = db.name; - try { - await new Promise((resolve, reject) => { - const deleteReq = indexedDB.deleteDatabase(dbName); - const timeout = setTimeout(() => { - console.warn('IndexedDB delete timeout:', dbName); - resolve(); - }, 5000); - deleteReq.onblocked = () => { - console.warn('IndexedDB delete blocked:', dbName); - clearTimeout(timeout); - resolve(); - }; - 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 }; - }, - 'clear IndexedDB', - ); + return runCleanScript(tabId, clearAllIndexedDBs, 'clear IndexedDB', [ + INDEXED_DB_DELETE_TIMEOUT_MS, + INDEXED_DB_CLEAR_STORE_TIMEOUT_MS, + INDEXED_DB_DELETE_FALLBACK_DELAY_MS, + ]); } async function injectClearCacheStorage(tabId: number): Promise { @@ -333,6 +344,11 @@ export async function clearStorage( ); if (failures.length > 0) { result.overallSuccess = false; + result.error = CLEAN_OPTION_KEYS.flatMap((key) => { + const itemResult = result[key]; + if (!itemResult || itemResult.success) return []; + return `${OPTION_LABELS[key]}: ${itemResult.error}`; + }).join('\n'); } return result;