// HLS.js detection and tracking verification tests // Covers scenarios specific to sites that use hls.js for video playback: // - Dynamic/late HLS.js loading (polling + MutationObserver) // - Content script message collection // - Video playback state machine transitions // - HLS.js error recovery // - Manifest URL edge cases (query strings, protocol-relative) // - End-to-end interceptor -> content script -> deep probe pipeline import { describe, it } from './runner.mjs'; import { JSDOM } from 'jsdom'; import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const viewerSource = fs.readFileSync( path.join(__dirname, '../src/viewer/viewer.js'), 'utf-8' ); const interceptorSource = fs.readFileSync( path.join(__dirname, '../src/inject/interceptors.js'), 'utf-8' ); const contentSource = fs.readFileSync( path.join(__dirname, '../src/content/content.js'), 'utf-8' ); const indexSource = fs.readFileSync( path.join(__dirname, '../src/viewer/index.js'), 'utf-8' ); /* ========================================================================== */ /* Helpers */ /* ========================================================================== */ function createBrowserEnv({ storageData = {}, url = 'https://example.com/viewer/viewer.html', hlsMode = 'mocked' } = {}) { const html = `
`; const dom = new JSDOM(html, { url, runScripts: 'dangerously', resources: 'usable', }); const win = dom.window; const storage = { ...storageData }; win.chrome = { storage: { local: { get: (keys) => new Promise((resolve) => { const result = {}; for (const key of (keys || [])) result[key] = storage[key]; resolve(result); }), set: (data) => new Promise((resolve) => { for (const [k, v] of Object.entries(data)) storage[k] = v; resolve(); }), remove: (keys) => new Promise((resolve) => { for (const key of (keys || [])) delete storage[key]; resolve(); }), }, }, }; const hlsCalls = { instances: [], loaded: false }; if (hlsMode === 'mocked') { win.Hls = class MockHLS { constructor(opts) { this.levels = []; hlsCalls.instances.push({ opts, destroyed: false, events: {}, source: null, media: null, levels: [], }); } static get Events() { return { MANIFEST_PARSED: 'manifestParsed', MANIFEST_LOADED: 'manifestLoaded', FRAG_LOADING: 'fragLoading', FRAG_LOADED: 'fragLoaded', FRAG_BUFFERED: 'fragBuffered', BUFFER_APPENDED: 'bufferAppended', BUFFER_FLUSHED: 'bufferFlushed', BUFFER_RESET: 'bufferReset', LEVEL_SWITCHED: 'levelSwitched', LEVEL_UPDATED: 'levelUpdated', FPS_DROP: 'fpsDrop', FPS_MAX_LEVEL_DROP: 'fpsMaxLevelDrop', MEDIA_ATTACHED: 'mediaAttached', MEDIA_DETACHED: 'mediaDetached', ERROR: 'error', DESTROYING: 'destroying', }; } static get ErrorTypes() { return { NETWORK_ERROR: 'networkError', MEDIA_ERROR: 'mediaError' }; } static isSupported() { return true; } loadSource(src) { const inst = hlsCalls.instances[hlsCalls.instances.length - 1]; inst.source = src; } attachMedia(media) { const inst = hlsCalls.instances[hlsCalls.instances.length - 1]; inst.media = media; } on(event, handler) { const inst = hlsCalls.instances[hlsCalls.instances.length - 1]; if (!inst.events[event]) inst.events[event] = []; inst.events[event].push(handler); } destroy() { const inst = hlsCalls.instances[hlsCalls.instances.length - 1]; inst.destroyed = true; } startLoad() {} recoverMediaError() {} }; hlsCalls.loaded = true; } const consoleLogs = []; win.console = { log: (...args) => consoleLogs.push(args.map(String).join(' ')), error: (...args) => consoleLogs.push('ERROR: ' + args.map(String).join(' ')), warn: (...args) => consoleLogs.push('WARN: ' + args.map(String).join(' ')), }; return { dom, win, storage, consoleLogs, hlsCalls }; } /* ========================================================================== */ /* Test: Dynamic/late HLS.js loading (polling + MutationObserver) */ /* ========================================================================== */ describe('HLS.js: Dynamic late loading detection', () => { it('should patch Hls prototype via polling when Hls appears after interceptors load', () => { const dom = new JSDOM('', { url: 'https://example.com/watch', runScripts: 'dangerously', }); const win = dom.window; const intercepted = []; win.postMessage = (msg) => { if (msg.type === 'GOON_PLUGIN_HLS_INTERCEPT') intercepted.push(msg); }; win.fetch = () => {}; // Load interceptors BEFORE Hls is defined win.eval(interceptorSource); // Simulate hls.js loading dynamically AFTER interceptors win.Hls = class { loadSource(url) { this._url = url; } attachMedia(media) {} }; Object.defineProperty(win.Hls.prototype, 'url', { get() { return this._url || ''; } }); // Wait for polling to detect and patch Hls (polls every 50ms) return new Promise((resolve, reject) => { setTimeout(() => { try { const hls = new win.Hls(); hls.loadSource('https://cdn.example.com/late.m3u8'); const hlsIntercepts = intercepted.filter(m => m.source === 'hls_loadSource'); if (hlsIntercepts.length === 0) { throw new Error('Late-loaded HLS was not intercepted via polling. Sources: ' + intercepted.map(m => m.source).join(', ')); } resolve(); } catch (e) { reject(e); } }, 200); }); }); it('should detect HLS.js loaded via