From 3fb99ea49ff6571991ac2b2cd2d603d00d29eae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=A8=E9=9C=96=E9=93=83?= Date: Wed, 20 May 2026 20:41:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=8F=B3=E9=94=AE?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=B3=A8=E5=86=8C=E4=B8=8E=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E5=88=86=E6=B5=81=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 utils/contextMenu.ts 封装菜单配置和解析函数 - 在 background.ts 监听 onInstalled 初始化菜单 - 实现 contextMenus.onClicked 点击事件处理 - 分流逻辑:优先发送到侧边栏,否则打开 options 页面 - 添加 contextMenu 单元测试(15个测试用例) - 更新 vitest.setup.ts 添加 contextMenus mock --- entrypoints/background.ts | 30 ++++- utils/__tests__/contextMenu.test.ts | 191 ++++++++++++++++++++++++++++ utils/contextMenu.ts | 113 ++++++++++++++++ vitest.setup.ts | 22 ++++ 4 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 utils/__tests__/contextMenu.test.ts create mode 100644 utils/contextMenu.ts diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 549ca01..97d08d3 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -1,8 +1,36 @@ import '../.wxt/types/imports.d.ts'; import { browser } from 'wxt/browser'; -import { MessageAction, onMessage } from '@/utils/messages'; +import { MessageAction, onMessage, sendMessage } from '@/utils/messages'; +import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu'; export default defineBackground(() => { + browser.runtime.onInstalled.addListener(() => { + createAllContextMenus(); + }); + + browser.contextMenus.onClicked.addListener(async (info, _tab) => { + const result = parseContextMenuClick(info.menuItemId as string, info); + if (!result) return; + + const { featureKey, payload } = result; + + try { + const sidePanelState = await browser.storage.local.get('sidePanelOpen'); + const isSidePanelOpen = sidePanelState.sidePanelOpen === true; + + if (isSidePanelOpen) { + await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload }); + return; + } + } catch { + // sidepanel 未打开或无法通信,继续执行其他方案 + } + + const optionsUrl = chrome.runtime.getURL('/entrypoints/options/index.html'); + const params = new URLSearchParams({ feature: featureKey, payload }); + await browser.tabs.create({ url: `${optionsUrl}?${params.toString()}` }); + }); + // 监听扩展图标点击事件,打开侧边栏 browser.action.onClicked.addListener(async (tab) => { if (tab.id) { diff --git a/utils/__tests__/contextMenu.test.ts b/utils/__tests__/contextMenu.test.ts new file mode 100644 index 0000000..887b490 --- /dev/null +++ b/utils/__tests__/contextMenu.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { + CONTEXT_MENU_CONFIGS, + createAllContextMenus, + parseContextMenuClick, +} from '@/utils/contextMenu'; + +describe('contextMenu', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('CONTEXT_MENU_CONFIGS', () => { + it('应该包含 8 个菜单项配置', () => { + expect(CONTEXT_MENU_CONFIGS).toHaveLength(8); + }); + + it('应该有一个父级菜单项 Testing Tools', () => { + const parentMenu = CONTEXT_MENU_CONFIGS.find((c) => c.id === 'testing-tools-parent'); + expect(parentMenu).toBeDefined(); + expect(parentMenu?.title).toBe('Testing Tools'); + expect(parentMenu?.parentId).toBeUndefined(); + }); + + it('应该有 4 个 selection 上下文的子菜单', () => { + const selectionMenus = CONTEXT_MENU_CONFIGS.filter( + (c) => c.contexts[0] === 'selection' && c.parentId === 'testing-tools-parent', + ); + expect(selectionMenus).toHaveLength(4); + expect(selectionMenus.map((m) => m.id)).toEqual([ + 'jwt', + 'base64Converter', + 'textStatistics', + 'timestamp', + ]); + }); + + it('应该有 1 个 image 上下文的子菜单', () => { + const imageMenus = CONTEXT_MENU_CONFIGS.filter( + (c) => c.contexts[0] === 'image' && c.parentId === 'testing-tools-parent', + ); + expect(imageMenus).toHaveLength(1); + expect(imageMenus[0].id).toBe('qrCode-image'); + }); + + it('应该有 2 个 page 上下文的子菜单', () => { + const pageMenus = CONTEXT_MENU_CONFIGS.filter( + (c) => c.contexts[0] === 'page' && c.parentId === 'testing-tools-parent', + ); + expect(pageMenus).toHaveLength(2); + expect(pageMenus.map((m) => m.id)).toEqual(['storageCleaner', 'qrCode-page']); + }); + }); + + describe('createAllContextMenus', () => { + it('应该为每个配置调用 chrome.contextMenus.create', () => { + createAllContextMenus(); + + expect(chrome.contextMenus.create).toHaveBeenCalledTimes(8); + }); + + it('应该使用正确的参数创建菜单项', () => { + createAllContextMenus(); + + expect(chrome.contextMenus.create).toHaveBeenCalledWith({ + id: 'testing-tools-parent', + title: 'Testing Tools', + contexts: ['all'], + parentId: undefined, + }); + + expect(chrome.contextMenus.create).toHaveBeenCalledWith({ + id: 'jwt', + title: '🔑 解析 JWT', + contexts: ['selection'], + parentId: 'testing-tools-parent', + }); + }); + }); + + describe('parseContextMenuClick', () => { + const createMockOnClickData = ( + overrides: Partial = {}, + ): chrome.contextMenus.OnClickData => ({ + menuItemId: 'test', + editable: false, + pageUrl: 'https://example.com', + ...overrides, + }); + + it('当点击 qrCode-image 菜单时应返回 qrCode 功能和 srcUrl', () => { + const info = createMockOnClickData({ + srcUrl: 'https://example.com/image.png', + }); + + const result = parseContextMenuClick('qrCode-image', info); + + expect(result).toEqual({ + featureKey: 'qrCode', + payload: 'https://example.com/image.png', + }); + }); + + it('当点击 qrCode-image 菜单但没有 srcUrl 时应返回空字符串', () => { + const info = createMockOnClickData({}); + + const result = parseContextMenuClick('qrCode-image', info); + + expect(result).toEqual({ + featureKey: 'qrCode', + payload: '', + }); + }); + + it('当点击 qrCode-page 菜单时应返回 qrCode 功能和 pageUrl', () => { + const info = createMockOnClickData({ + pageUrl: 'https://example.com/page', + }); + + const result = parseContextMenuClick('qrCode-page', info); + + expect(result).toEqual({ + featureKey: 'qrCode', + payload: 'https://example.com/page', + }); + }); + + it('当点击有 selectionText 的菜单时应返回对应功能和选中文本', () => { + const info = createMockOnClickData({ + selectionText: 'selected text', + }); + + const result = parseContextMenuClick('jwt', info); + + expect(result).toEqual({ + featureKey: 'jwt', + payload: 'selected text', + }); + }); + + it('当点击 timestamp 菜单时应正确映射功能键', () => { + const info = createMockOnClickData({ + selectionText: '1234567890', + }); + + const result = parseContextMenuClick('timestamp', info); + + expect(result).toEqual({ + featureKey: 'timestamp', + payload: '1234567890', + }); + }); + + it('当点击 storageCleaner 菜单时应返回 pageUrl', () => { + const info = createMockOnClickData({ + pageUrl: 'https://example.com', + }); + + const result = parseContextMenuClick('storageCleaner', info); + + expect(result).toEqual({ + featureKey: 'storageCleaner', + payload: 'https://example.com', + }); + }); + + it('当没有 selectionText 和 pageUrl 时应返回 null', () => { + const info = createMockOnClickData({ + pageUrl: undefined, + }); + + const result = parseContextMenuClick('someMenu', info); + + expect(result).toBeNull(); + }); + + it('selectionText 优先于 pageUrl', () => { + const info = createMockOnClickData({ + selectionText: 'selected text', + pageUrl: 'https://example.com', + }); + + const result = parseContextMenuClick('jwt', info); + + expect(result).toEqual({ + featureKey: 'jwt', + payload: 'selected text', + }); + }); + }); +}); diff --git a/utils/contextMenu.ts b/utils/contextMenu.ts new file mode 100644 index 0000000..1de7b8a --- /dev/null +++ b/utils/contextMenu.ts @@ -0,0 +1,113 @@ +import type { PageType } from '@/types/storage'; + +export interface ContextMenuItemConfig { + id: string; + title: string; + contexts: [`${chrome.contextMenus.ContextType}`, ...`${chrome.contextMenus.ContextType}`[]]; + parentId?: string; +} + +export interface ContextMenuClickedInfo { + featureKey: PageType; + payload: string; +} + +const PARENT_MENU_ID = 'testing-tools-parent'; + +export const CONTEXT_MENU_CONFIGS: ContextMenuItemConfig[] = [ + { + id: PARENT_MENU_ID, + title: 'Testing Tools', + contexts: [chrome.contextMenus.ContextType.ALL], + }, + { + id: 'jwt', + title: '🔑 解析 JWT', + contexts: [chrome.contextMenus.ContextType.SELECTION], + parentId: PARENT_MENU_ID, + }, + { + id: 'base64Converter', + title: '🔄 Base64 解码', + contexts: [chrome.contextMenus.ContextType.SELECTION], + parentId: PARENT_MENU_ID, + }, + { + id: 'textStatistics', + title: '📊 统计选中文本', + contexts: [chrome.contextMenus.ContextType.SELECTION], + parentId: PARENT_MENU_ID, + }, + { + id: 'timestamp', + title: '⏰ 转换时间戳', + contexts: [chrome.contextMenus.ContextType.SELECTION], + parentId: PARENT_MENU_ID, + }, + { + id: 'qrCode-image', + title: '🖼️ 识别图中的二维码', + contexts: [chrome.contextMenus.ContextType.IMAGE], + parentId: PARENT_MENU_ID, + }, + { + id: 'storageCleaner', + title: '🧹 清理当前网站存储', + contexts: [chrome.contextMenus.ContextType.PAGE], + parentId: PARENT_MENU_ID, + }, + { + id: 'qrCode-page', + title: '🔗 网页链接转二维码', + contexts: [chrome.contextMenus.ContextType.PAGE], + parentId: PARENT_MENU_ID, + }, +]; + +export function createAllContextMenus(): void { + for (const config of CONTEXT_MENU_CONFIGS) { + chrome.contextMenus.create({ + id: config.id, + title: config.title, + contexts: config.contexts, + parentId: config.parentId, + }); + } +} + +export function parseContextMenuClick( + menuItemId: string, + info: chrome.contextMenus.OnClickData, +): ContextMenuClickedInfo | null { + const featureKey = menuItemId as PageType; + + if (menuItemId === 'qrCode-image') { + return { + featureKey: 'qrCode', + payload: info.srcUrl || '', + }; + } + + if (menuItemId === 'qrCode-page') { + return { + featureKey: 'qrCode', + payload: info.pageUrl || '', + }; + } + + if (info.selectionText) { + return { + featureKey, + payload: info.selectionText, + }; + } + + if (info.pageUrl) { + return { + featureKey, + payload: info.pageUrl, + }; + } + + return null; +} diff --git a/vitest.setup.ts b/vitest.setup.ts index c6fb4ec..6544534 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -70,6 +70,28 @@ Object.defineProperty(global, 'chrome', { getAll: vi.fn().mockResolvedValue([]), remove: vi.fn().mockResolvedValue(undefined), }, + contextMenus: { + create: vi.fn(), + onClicked: { + addListener: vi.fn(), + removeListener: vi.fn(), + }, + ContextType: { + ALL: 'all', + PAGE: 'page', + FRAME: 'frame', + SELECTION: 'selection', + LINK: 'link', + EDITABLE: 'editable', + IMAGE: 'image', + VIDEO: 'video', + AUDIO: 'audio', + LAUNCHER: 'launcher', + BROWSER_ACTION: 'browser_action', + PAGE_ACTION: 'page_action', + ACTION: 'action', + }, + }, }, writable: true, });