ext: content/ media detection (build order step 7)
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
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MediaBridge, notifyTab } from '../../src/background/media-bridge.js';
|
||||
import type { ContentMessage } from '../../src/background/media-bridge.js';
|
||||
import type { VeloxTransport } from '../../src/background/transport/index.js';
|
||||
|
||||
function fakeOnMessage() {
|
||||
let listener: ((msg: ContentMessage, sender: unknown, sendResponse: (r: unknown) => void) => boolean | void) | undefined;
|
||||
return {
|
||||
addListener: (cb: typeof listener) => {
|
||||
listener = cb;
|
||||
},
|
||||
fire: (msg: ContentMessage) =>
|
||||
new Promise((resolve) => {
|
||||
listener?.(msg, {}, resolve);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeTransport(call: VeloxTransport['call']): VeloxTransport {
|
||||
return {
|
||||
kind: 'ws',
|
||||
state: 'connected',
|
||||
status: {
|
||||
state: 'connected',
|
||||
needsPairing: false,
|
||||
fatal: null,
|
||||
retryAfterSec: null,
|
||||
sessionId: null,
|
||||
daemonVersion: null,
|
||||
capabilities: [],
|
||||
},
|
||||
connect: async () => undefined,
|
||||
disconnect: () => undefined,
|
||||
call,
|
||||
on: () => undefined,
|
||||
off: () => undefined,
|
||||
onStateChange: () => () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MediaBridge', () => {
|
||||
it('answers velox-list-variants by calling media.listVariants', async () => {
|
||||
const call = vi.fn(async () => ({ variants: [], manifestType: 'hls', drmProtected: false }));
|
||||
const bridge = new MediaBridge(() => fakeTransport(call as never));
|
||||
const onMessage = fakeOnMessage();
|
||||
bridge.attach(onMessage);
|
||||
|
||||
const response = await onMessage.fire({ type: 'velox-list-variants', params: { manifestUrl: 'https://x/m.m3u8' } });
|
||||
|
||||
expect(call).toHaveBeenCalledWith('media.listVariants', { manifestUrl: 'https://x/m.m3u8' });
|
||||
expect(response).toEqual({ ok: true, result: { variants: [], manifestType: 'hls', drmProtected: false } });
|
||||
});
|
||||
|
||||
it('answers velox-add-variant by calling media.addVariant', async () => {
|
||||
const call = vi.fn(async () => ({ taskId: 't1', state: 'queued' }));
|
||||
const bridge = new MediaBridge(() => fakeTransport(call as never));
|
||||
const onMessage = fakeOnMessage();
|
||||
bridge.attach(onMessage);
|
||||
|
||||
const response = await onMessage.fire({
|
||||
type: 'velox-add-variant',
|
||||
params: { manifestUrl: 'https://x/m.m3u8', variantId: 'v1' },
|
||||
});
|
||||
|
||||
expect(call).toHaveBeenCalledWith('media.addVariant', { manifestUrl: 'https://x/m.m3u8', variantId: 'v1' });
|
||||
expect(response).toEqual({ ok: true, result: { taskId: 't1', state: 'queued' } });
|
||||
});
|
||||
|
||||
it('answers with an error when the transport is not ready', async () => {
|
||||
const bridge = new MediaBridge(() => undefined);
|
||||
const onMessage = fakeOnMessage();
|
||||
bridge.attach(onMessage);
|
||||
|
||||
const response = await onMessage.fire({ type: 'velox-list-variants', params: { manifestUrl: 'https://x/m.m3u8' } });
|
||||
expect(response).toEqual({ ok: false, error: 'transport not ready' });
|
||||
});
|
||||
|
||||
it('answers with an error when the call rejects', async () => {
|
||||
const call = vi.fn(async () => {
|
||||
throw new Error('refused: drm protected');
|
||||
});
|
||||
const bridge = new MediaBridge(() => fakeTransport(call as never));
|
||||
const onMessage = fakeOnMessage();
|
||||
bridge.attach(onMessage);
|
||||
|
||||
const response = await onMessage.fire({ type: 'velox-list-variants', params: { manifestUrl: 'https://x/m.m3u8' } });
|
||||
expect(response).toEqual({ ok: false, error: 'refused: drm protected' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifyTab', () => {
|
||||
it('sends a velox-manifest-detected message to the tab', () => {
|
||||
const sent: unknown[] = [];
|
||||
const tabs = { sendMessage: (tabId: number, message: unknown) => { sent.push({ tabId, message }); return Promise.resolve(); } };
|
||||
|
||||
notifyTab(tabs, { tabId: 3, url: 'https://x/m.m3u8', headers: [] });
|
||||
|
||||
expect(sent).toEqual([{ tabId: 3, message: { type: 'velox-manifest-detected', url: 'https://x/m.m3u8', headers: [] } }]);
|
||||
});
|
||||
|
||||
it('swallows a rejection (tab has no content script listening)', () => {
|
||||
const tabs = { sendMessage: () => Promise.reject(new Error('no receiving end')) };
|
||||
expect(() => notifyTab(tabs, { tabId: 3, url: 'https://x/m.m3u8', headers: [] })).not.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user