Files
testing-tool/spec/storage-cleaner/indexeddb-fix-plan.md

16 KiB
Raw Permalink Blame History

Storage Cleaner — IndexedDB 修复方案与验收标准

创建时间: 2026-06-25 状态: Phase 3 已完成(2026-06-25 关联模块: src/utils/storageCleaner.ts 前置审查: Code ReviewstorageCleaner.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 完成后再关闭连接

问题

clearObjectStoresPromise.all(clearStore...) 结束后立刻 db.close()。单个 clearReq.onsuccess 只表示 request 完成,transaction 可能尚未 commit,存在清空被回滚的风险。

根因

IndexedDB 规范中,transaction 的持久化以 transaction.oncomplete 为准,而非单个 request 的 onsuccess

修复方案

在注入脚本内的 clearObjectStores 中,增加 waitForTransaction 辅助函数:

const waitForTransaction = (tx: IDBTransaction): Promise<void> =>
  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 分支:

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.tsinjectClearIndexedDBclearObjectStores

新增测试

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 状态:

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 删除逻辑

新增测试

it('should ignore late onsuccess after delete timeout', async () => {
  // deleteBehavior: timeout5000ms 后 resolve timeout
  // 6000ms 后再触发 onsuccess
  // 断言:只走 fallback 一次,count 不因 late onsuccess 重复 +1
});

Phase 2 — 建议同 PR 或紧接 follow-upP2

2.1 IndexedDB 部分成功时保留 count

问题

多 DB 场景返回 { count: 2, errors: ['...'] } 时,runCleanScript 只返回 { success: false, error },用户看不到已清理 2 个库。

修复方案(推荐)

扩展失败分支类型,可选 count

// src/types/storage.d.ts
export type StorageCleanResult =
  | { success: true; count: number }
  | { success: false; error: string; count?: number }; // 部分成功时的已清理数

修改 runCleanScript

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.tsrunCleanScript
  • src/utils/__tests__/storageCleaner.test.ts
  • (可选)src/pages/StorageCleaner/components/CleaningResult.tsx

新增测试

it('should preserve partial count when some IndexedDB databases fail', async () => {
  // 3 个 DB2 成功删除,1 blocked 且 fallback 失败
  // expect: success false, count 2, error 含「已成功清理 2 个」
});

2.2 统一 executeScript 调用入口

问题

runScriptrunCleanScript 各自调用 browser.scripting.executeScript,行为不一致(吞错 vs 抛错)。

修复方案

抽取底层函数:

type ExecuteScriptMode = 'fallback' | 'throw';

async function executeInTab<T>(
  tabId: number,
  func: () => T | Promise<T>,
  options: { errorLabel: string; mode: 'fallback'; fallback: T },
): Promise<T>;
async function executeInTab<T>(
  tabId: number,
  func: () => T | Promise<T>,
  options: { errorLabel: string; mode: 'throw' },
): Promise<T>;
async function executeInTab<T>(...) {
  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;
  }
}
  • runScriptexecuteInTab(..., { mode: 'fallback', fallback })
  • runCleanScriptexecuteInTab(..., { mode: 'throw' }) + 结果解析

涉及文件

  • src/utils/storageCleaner.ts

验收

现有 11 个测试全部通过,无行为回归。


2.3 错误信息分隔符统一

问题

runCleanScript'; ' 拼接,clearStorageresult.error'\n'UI 用 break-all 展示,多错误时可读性不一致。

修复方案

IndexedDB 内部多错误统一改为 '\n'

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 超时常量提升到模块级

// 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 逻辑拆分(长期)

clearObjectStoreswaitForDeleteDatabase 等抽到 src/utils/indexedDbCleaner.ts 的纯函数,注入层只做:

async () => clearAllIndexedDBs(INDEXED_DB_DELETE_TIMEOUT_MS);

便于单测,不依赖 mockExecuteScriptEval 间接执行注入函数。工作量大,建议单独 PR。


实施顺序

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 必须通过)

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 单 DBdeleteDatabase 成功 indexedDB: { success: true, count: 1 }overallSuccess: true
B-2 单 DBdeleteDatabase blockedfallback clear 成功 success: true, count: 1transaction 在 oncompletedb.close
B-3 单 DBdelete 超时 5sfallback clear 成功 5s 内进入 fallback;不因 late onsuccess 重复计数
B-4 fallback 中某 store clear hang 5s success: falseerror 含 dbName/storeName
B-5 3 DB2 成功 + 1 失败 success: falsecount: 2Phase 2.1 后),error 含失败库名与部分成功提示
B-6 executeScript 注入失败 success: false不得 { success: true, count: 0 }
B-7 localStorage 成功 + cookies 失败 overallSuccess: falseresult.errorCookies: ...(换行分隔多项失败)

C. 回归与 UI

编号 标准
C-1 formatCleaningResult 成功路径不变
C-2 CleaningResult 失败时展示 result.error;含 \n 时多行可读(现有 leading-relaxed break-all 可接受)
C-3 reloadAfterClean=trueoverallSuccess=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 检查清单

## 修复内容

- [ ] 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