Two independent signals feed one panel, per docs/05 §3: - capture/media.ts (background): webRequest-based, recognizes .m3u8/.mpd URLs and the HLS/DASH content types, deduped per (tab, url) so an HLS live-refresh doesn't re-fire. media-bridge.ts relays a hit to the tab's content script over runtime.sendMessage, and separately answers the content script's media.listVariants/media.addVariant calls by forwarding them to the background page's transport. - content/media-observer.ts: watches the page's own <video> elements (present now, added later, or with src changed) for the same extension signal, independent of what the network sniffer saw. Either firing opens content/video-panel.ts's "Download this video ▾" panel (built with createElement, matching the popup's innerHTML-free approach), which lists variants from media.listVariants and greys out any variant.drm or a wholly drmProtected manifest with "Protected content" rather than attempting it. The extension still never parses a manifest itself — that stays in the daemon, one language, one place. content/index.ts is the manifest-registered entry (content_scripts in manifest.json, added in the previous commit); build.mjs builds it as an IIFE rather than ESM, since a manifest content script has no "type": "module" declaration and an emitted top-level export would be a syntax error there. tsconfig.json adds DOM.Iterable for NodeList iteration. docs/05-extension-spec.md gets a short addendum (§8) documenting the popup/options bridge and this media-detection split, since neither was in the original design write-up. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Ed8KEmAW48v4YHdxLtqsMB
108 lines
4.7 KiB
TypeScript
108 lines
4.7 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { VideoPanel, type VariantsPort } from '../../src/content/video-panel.js';
|
|
import type { MediaVariant } from '../../src/shared/protocol/index.js';
|
|
|
|
function variant(over: Partial<MediaVariant> = {}): MediaVariant {
|
|
return { variantId: 'v1', kind: 'muxed', drm: false, ...over };
|
|
}
|
|
|
|
describe('VideoPanel', () => {
|
|
beforeEach(() => {
|
|
document.body.innerHTML = '';
|
|
});
|
|
|
|
it('renders a button and an initially-hidden menu', () => {
|
|
const port: VariantsPort = { listVariants: vi.fn(), addVariant: vi.fn() };
|
|
const panel = new VideoPanel(port);
|
|
panel.show('https://cdn.example/m.m3u8');
|
|
|
|
const button = document.querySelector('#velox-video-panel button')!;
|
|
expect(button.textContent).toBe('Download this video ▾');
|
|
expect((document.querySelector('#velox-video-panel ul') as HTMLUListElement).hidden).toBe(true);
|
|
});
|
|
|
|
it('lists variants on click, skipping the daemon\'s manifest parsing entirely', async () => {
|
|
const listVariants = vi.fn(async () => ({
|
|
variants: [variant({ variantId: 'v-1080', resolution: '1920x1080' })],
|
|
drmProtected: false,
|
|
}));
|
|
const port: VariantsPort = { listVariants, addVariant: vi.fn() };
|
|
const panel = new VideoPanel(port);
|
|
panel.show('https://cdn.example/m.m3u8');
|
|
|
|
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
|
|
await vi.waitFor(() => expect(listVariants).toHaveBeenCalledWith('https://cdn.example/m.m3u8'));
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
const items = document.querySelectorAll('#velox-video-panel li');
|
|
expect(items.length).toBe(1);
|
|
expect(items[0]!.textContent).toContain('1920x1080');
|
|
});
|
|
|
|
it('greys out a DRM variant with "Protected content" and does not wire a click handler', async () => {
|
|
const listVariants = vi.fn(async () => ({
|
|
variants: [variant({ variantId: 'v-drm', drm: true, resolution: '1920x1080' })],
|
|
drmProtected: false,
|
|
}));
|
|
const addVariant = vi.fn();
|
|
const panel = new VideoPanel({ listVariants, addVariant });
|
|
panel.show('https://cdn.example/m.m3u8');
|
|
|
|
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
const item = document.querySelector<HTMLButtonElement>('#velox-video-panel li button')!;
|
|
expect(item.disabled).toBe(true);
|
|
expect(item.textContent).toContain('Protected content');
|
|
|
|
item.dispatchEvent(new Event('click'));
|
|
expect(addVariant).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows "Protected content" for a wholly DRM-protected manifest', async () => {
|
|
const listVariants = vi.fn(async () => ({ variants: [variant()], drmProtected: true }));
|
|
const panel = new VideoPanel({ listVariants, addVariant: vi.fn() });
|
|
panel.show('https://cdn.example/m.m3u8');
|
|
|
|
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
expect(document.querySelector('#velox-video-panel li')!.textContent).toBe('Protected content');
|
|
});
|
|
|
|
it('calls addVariant when a non-DRM item is clicked', async () => {
|
|
const listVariants = vi.fn(async () => ({ variants: [variant({ variantId: 'v-720' })], drmProtected: false }));
|
|
const addVariant = vi.fn(async () => ({ taskId: 't1' }));
|
|
const panel = new VideoPanel({ listVariants, addVariant });
|
|
panel.show('https://cdn.example/m.m3u8');
|
|
|
|
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
document.querySelector<HTMLButtonElement>('#velox-video-panel li button')!.dispatchEvent(new Event('click'));
|
|
|
|
expect(addVariant).toHaveBeenCalledWith('https://cdn.example/m.m3u8', 'v-720');
|
|
});
|
|
|
|
it('surfaces an error from listVariants instead of an empty menu', async () => {
|
|
const listVariants = vi.fn(async () => ({ error: 'refused: not a manifest' }));
|
|
const panel = new VideoPanel({ listVariants, addVariant: vi.fn() });
|
|
panel.show('https://cdn.example/m.m3u8');
|
|
|
|
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
expect(document.querySelector('#velox-video-panel li')!.textContent).toContain('refused: not a manifest');
|
|
});
|
|
|
|
it('show() replaces a previous panel rather than stacking them', () => {
|
|
const port: VariantsPort = { listVariants: vi.fn(), addVariant: vi.fn() };
|
|
const panel = new VideoPanel(port);
|
|
panel.show('https://cdn.example/a.m3u8');
|
|
panel.show('https://cdn.example/b.m3u8');
|
|
|
|
expect(document.querySelectorAll('#velox-video-panel')).toHaveLength(1);
|
|
});
|
|
});
|