fix(StorageCleaner): 修复 IndexedDB 清理竞态、部分成功计数与刷新后状态同步
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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(<Index />);
|
||||
expect(screen.getByText(/正在读取站点数据/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/正在读取数据/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('读取当前标签页失败时应显示错误提示', async () => {
|
||||
vi.mocked(getCurrentTab).mockRejectedValueOnce(new Error('Tabs unavailable'));
|
||||
|
||||
render(<Index />);
|
||||
|
||||
expect(await screen.findByText('读取数据失败')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('确认清理前如果当前标签页变为受限页面,不应执行清理', async () => {
|
||||
render(<Index />);
|
||||
|
||||
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(<Index />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<HTMLDivElement> {
|
||||
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({
|
||||
<div className={cn('w-full overflow-hidden', className)} {...props}>
|
||||
<div className="px-3.5 pt-3.5 pb-2">
|
||||
<div className="grid grid-cols-2 gap-2 items-stretch">
|
||||
{optionKeys.map((key) => (
|
||||
{CLEAN_OPTION_KEYS.map((key) => (
|
||||
<OptionItem
|
||||
key={key}
|
||||
labelKey={key}
|
||||
|
||||
@@ -12,7 +12,7 @@ export const CLEAN_OPTION_KEYS = [
|
||||
export const OPTION_LABELS: Record<(typeof CLEAN_OPTION_KEYS)[number], string> = {
|
||||
localStorage: 'Local Storage',
|
||||
sessionStorage: 'Session Storage',
|
||||
indexedDB: '站点存储',
|
||||
indexedDB: 'IndexedDB',
|
||||
cookies: 'Cookies',
|
||||
cacheStorage: 'Cache Storage',
|
||||
serviceWorkers: 'Service Workers',
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function Index() {
|
||||
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full">
|
||||
<Loader2 className="h-6 w-6 text-muted-foreground/80" />
|
||||
<span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide">
|
||||
{'正在读取站点数据...'}
|
||||
{'正在读取数据...'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<void> {
|
||||
await new Promise<void>((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();
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -183,6 +183,8 @@ export type StorageCleanResult =
|
||||
success: false;
|
||||
/** 错误信息 */
|
||||
error: string;
|
||||
/** 部分成功时的已清理数量(如 IndexedDB 多库场景) */
|
||||
count?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void>;
|
||||
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<string, DeleteDatabaseBehavior>;
|
||||
|
||||
function createIndexedDBMock(options: {
|
||||
databases: Array<{ name: string }>;
|
||||
deleteBehavior: DeleteBehaviorConfig;
|
||||
open?: ReturnType<typeof createOpenMock>;
|
||||
}) {
|
||||
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<T>(indexedDBMock: object, run: () => Promise<T>): Promise<T> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<unknown>;
|
||||
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),请刷新后重试',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<IndexedDBCleanResult> {
|
||||
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const waitForTransaction = (tx: IDBTransaction, timeoutMs: number): Promise<void> =>
|
||||
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<IDBDatabase> =>
|
||||
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<string | null> =>
|
||||
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)}`] };
|
||||
}
|
||||
}
|
||||
+83
-67
@@ -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<number> {
|
||||
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<number> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用辅助:在指定标签页中执行脚本并返回结果
|
||||
*
|
||||
* @param tabId 标签页 ID
|
||||
* @param func 在页面上下文中执行的函数
|
||||
* @param errorLabel 错误日志前缀
|
||||
* @param fallback 执行失败时的回退值
|
||||
*/
|
||||
async function runScript<T>(
|
||||
type ExecuteInTabOptions<T> =
|
||||
| { errorLabel: string; mode: 'fallback'; fallback: T }
|
||||
| { errorLabel: string; mode: 'throw' };
|
||||
|
||||
async function executeInTab<T, A extends unknown[] = []>(
|
||||
tabId: number,
|
||||
func: () => T | Promise<T>,
|
||||
errorLabel: string,
|
||||
fallback: T,
|
||||
func: (...args: A) => T | Promise<T>,
|
||||
options: ExecuteInTabOptions<T>,
|
||||
args?: A,
|
||||
): Promise<T> {
|
||||
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<T, A extends unknown[] = []>(
|
||||
tabId: number,
|
||||
func: (...args: A) => T | Promise<T>,
|
||||
errorLabel: string,
|
||||
fallback: T,
|
||||
args?: A,
|
||||
): Promise<T> {
|
||||
return executeInTab(tabId, func, { errorLabel, mode: 'fallback', fallback }, args);
|
||||
}
|
||||
|
||||
export async function getLocalStorageSize(tabId: number): Promise<number> {
|
||||
return runScript(
|
||||
tabId,
|
||||
@@ -164,12 +194,12 @@ export async function getServiceWorkerCount(tabId: number): Promise<number> {
|
||||
|
||||
export async function clearCookies(url: string): Promise<StorageCleanResult> {
|
||||
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<StorageCleanResult> {
|
||||
* @param func 在页面上下文中执行的清理函数
|
||||
* @param errorLabel 错误日志前缀
|
||||
*/
|
||||
async function runCleanScript(
|
||||
async function runCleanScript<A extends unknown[] = []>(
|
||||
tabId: number,
|
||||
func: () => { count: number } | Promise<{ count: number }>,
|
||||
func: (...args: A) => CleanScriptResult | Promise<CleanScriptResult>,
|
||||
errorLabel: string,
|
||||
args?: A,
|
||||
): Promise<StorageCleanResult> {
|
||||
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<StorageCleanResult> {
|
||||
@@ -225,47 +272,11 @@ async function injectClearSessionStorage(tabId: number): Promise<StorageCleanRes
|
||||
}
|
||||
|
||||
async function injectClearIndexedDB(tabId: number): Promise<StorageCleanResult> {
|
||||
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<void>((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<StorageCleanResult> {
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user