import { describe, expect, it, vi } from 'vitest'; import type { DownloadSpec } from '../src/shared/protocol/index.js'; import { ContextMenus, GRAB_TAB_COMMAND, LINK_MENU_ID, MEDIA_MENU_ID, type ContextMenusDeps, } from '../src/background/context-menus.js'; class Event { fns: Array<(...a: A) => void> = []; addListener = (f: (...a: A) => void): void => void this.fns.push(f); removeListener = (f: (...a: A) => void): void => { this.fns = this.fns.filter((x) => x !== f); }; emit(...a: A): void { for (const f of this.fns) f(...a); } } function harness(over: Partial = {}) { const created: Array<{ id: string; contexts: string[] }> = []; const onClicked = new Event<[{ menuItemId: string | number; linkUrl?: string; srcUrl?: string; pageUrl?: string }, unknown]>(); const onCommand = new Event<[string, unknown]>(); const menus = { create: (p: { id: string; title: string; contexts: string[] }) => void created.push({ id: p.id, contexts: p.contexts }), removeAll: vi.fn(async () => undefined), onClicked, }; const addDownload = vi.fn(async (_spec: DownloadSpec) => ({ taskId: 't' })); const tabs = { query: vi.fn(async () => [{ url: 'https://example.com/watch' }]) }; const onError = vi.fn(); const deps: ContextMenusDeps = { addDownload: addDownload as unknown as ContextMenusDeps['addDownload'], menus: menus as unknown as ContextMenusDeps['menus'], tabs: tabs as unknown as ContextMenusDeps['tabs'], commands: { onCommand } as unknown as ContextMenusDeps['commands'], onError, ...over, }; return { cm: new ContextMenus(deps), created, onClicked, onCommand, addDownload, tabs, onError, menus }; } describe('ContextMenus', () => { it('registers a link item and a media item after clearing stale ones', async () => { const { cm, created, menus } = harness(); await cm.register(); expect(menus.removeAll).toHaveBeenCalled(); expect(created).toEqual([ { id: LINK_MENU_ID, contexts: ['link'] }, { id: MEDIA_MENU_ID, contexts: ['image', 'video', 'audio'] }, ]); }); it('a link click sends linkUrl + pageUrl referrer to download.add', async () => { const { cm, onClicked, addDownload } = harness(); await cm.register(); onClicked.emit( { menuItemId: LINK_MENU_ID, linkUrl: 'https://cdn.example.com/a.zip', pageUrl: 'https://example.com/p' }, undefined, ); await Promise.resolve(); expect(addDownload).toHaveBeenCalledWith({ url: 'https://cdn.example.com/a.zip', referrer: 'https://example.com/p', } satisfies DownloadSpec); }); it('a media click uses srcUrl', async () => { const { cm, onClicked, addDownload } = harness(); await cm.register(); onClicked.emit({ menuItemId: MEDIA_MENU_ID, srcUrl: 'https://v.example.com/clip.mp4', pageUrl: 'https://v.example.com' }, undefined); await Promise.resolve(); expect(addDownload.mock.calls[0]![0].url).toBe('https://v.example.com/clip.mp4'); }); it('ignores a click on some other extension menu item', async () => { const { cm, onClicked, addDownload } = harness(); await cm.register(); onClicked.emit({ menuItemId: 'someone-elses-item', linkUrl: 'https://x/a.zip' }, undefined); await Promise.resolve(); expect(addDownload).not.toHaveBeenCalled(); }); it('rejects a non-http link with a user-facing error, no download', async () => { const { cm, onClicked, addDownload, onError } = harness(); await cm.register(); onClicked.emit({ menuItemId: LINK_MENU_ID, linkUrl: 'ftp://legacy/a.zip' }, undefined); await Promise.resolve(); expect(addDownload).not.toHaveBeenCalled(); expect(onError).toHaveBeenCalledWith(expect.stringMatching(/http/)); }); it('the grab-tab command downloads the active tab URL', async () => { const { cm, onCommand, addDownload, tabs } = harness(); await cm.register(); onCommand.emit(GRAB_TAB_COMMAND, undefined); await Promise.resolve(); await Promise.resolve(); expect(tabs.query).toHaveBeenCalled(); expect(addDownload.mock.calls[0]![0].url).toBe('https://example.com/watch'); }); it('the grab-tab command prefers the tab handed to the listener', async () => { const { cm, onCommand, addDownload, tabs } = harness(); await cm.register(); onCommand.emit(GRAB_TAB_COMMAND, { url: 'https://direct.example/page' }); await Promise.resolve(); expect(tabs.query).not.toHaveBeenCalled(); expect(addDownload.mock.calls[0]![0].url).toBe('https://direct.example/page'); }); it('surfaces a download.add failure through onError', async () => { const { cm, onClicked, onError } = harness({ addDownload: vi.fn().mockRejectedValue(new Error('daemon down')) as unknown as ContextMenusDeps['addDownload'], }); await cm.register(); onClicked.emit({ menuItemId: LINK_MENU_ID, linkUrl: 'https://x/a.zip' }, undefined); await Promise.resolve(); await Promise.resolve(); expect(onError).toHaveBeenCalledWith(expect.stringMatching(/daemon down/)); }); it('dispose() detaches listeners', async () => { const { cm, onClicked, addDownload } = harness(); await cm.register(); cm.dispose(); onClicked.emit({ menuItemId: LINK_MENU_ID, linkUrl: 'https://x/a.zip' }, undefined); await Promise.resolve(); expect(addDownload).not.toHaveBeenCalled(); }); });