diff --git a/__tests__/hls_detection.test.mjs b/__tests__/hls_detection.test.mjs new file mode 100644 index 0000000..affec3b --- /dev/null +++ b/__tests__/hls_detection.test.mjs @@ -0,0 +1,932 @@ +// 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