Files
vdm/extension/tests/background/media-bridge.test.ts
samiandClaude Sonnet 5 03b6253b5a 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
2026-09-11 12:26:48 +04:00

107 lines
4.0 KiB
TypeScript

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();
});
});