CI / test (pull_request) Failing after 24s
Two fixes addressing kfj001 review #37 on commit b19035a:
1. Vendor hls.js bundle locally — chrome.runtime.getURL('hls.min.js')
instead of CDN, bypassing site CSP (script-src 'self') blocks.
Added hls.min.js to vite.config.ts entry points, manifest web_accessible_resources.
2. Handle null entries in executeScript results — destroyed frames
return null when allFrames:true, causing 'Cannot read properties of
null (reading 'result')' in probeHlsManifests() and scrapeTab().
Added null guards before accessing .result.
83/83 tests pass. Build: 10 modules, 413KB hls bundle.
933 lines
36 KiB
JavaScript
933 lines
36 KiB
JavaScript
// 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 = `<!DOCTYPE html><html><body><div id="panels"></div></body></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('<!DOCTYPE html><html><body></body></html>', {
|
|
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 <script> tag injection (MutationObserver)', () => {
|
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
|
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 exists
|
|
win.eval(interceptorSource);
|
|
|
|
if (win.eval('typeof Hls') !== 'undefined') {
|
|
throw new Error('Hls should not be defined before script tag injection');
|
|
}
|
|
|
|
// Simulate the page adding a <script> tag for hls.js
|
|
const scriptEl = win.document.createElement('script');
|
|
scriptEl.src = 'https://cdn.example.com/hls.min.js';
|
|
win.document.head.appendChild(scriptEl);
|
|
|
|
// After the MutationObserver fires, define Hls (simulating script load)
|
|
win.Hls = class {
|
|
loadSource(url) { this._url = url; }
|
|
attachMedia(media) {}
|
|
};
|
|
Object.defineProperty(win.Hls.prototype, 'url', {
|
|
get() { return this._url || ''; }
|
|
});
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const hls = new win.Hls();
|
|
hls.loadSource('https://cdn.example.com/mutation-observer.m3u8');
|
|
|
|
const hlsIntercepts = intercepted.filter(m => m.source === 'hls_loadSource');
|
|
if (hlsIntercepts.length === 0) {
|
|
throw new Error('MutationObserver did not trigger Hls patching');
|
|
}
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 200);
|
|
});
|
|
});
|
|
|
|
it('should NOT re-patch Hls prototype if already patched (idempotent patch)', () => {
|
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
|
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 = () => {};
|
|
|
|
win.Hls = class {
|
|
loadSource(url) { this._url = url; }
|
|
};
|
|
Object.defineProperty(win.Hls.prototype, 'url', {
|
|
get() { return this._url || ''; }
|
|
});
|
|
|
|
// Load interceptors twice
|
|
win.eval(interceptorSource);
|
|
win.eval(interceptorSource);
|
|
|
|
win.fetch('https://cdn.example.com/stream.m3u8');
|
|
|
|
if (intercepted.length !== 1) {
|
|
throw new Error(`Expected 1 intercept after double injection, got ${intercepted.length}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Test: Content script message collection */
|
|
/* ========================================================================== */
|
|
|
|
describe('HLS.js: Content script manifest collection', () => {
|
|
it('should collect manifests from postMessage (GOON_PLUGIN_HLS_INTERCEPT)', () => {
|
|
if (!contentSource.includes("event.data.type === 'GOON_PLUGIN_HLS_INTERCEPT'")) {
|
|
throw new Error('Content script missing GOON_PLUGIN_HLS_INTERCEPT message handler');
|
|
}
|
|
if (!contentSource.includes('interceptedSources.has(url)')) {
|
|
throw new Error('Content script does not deduplicate intercepted manifests');
|
|
}
|
|
if (!contentSource.includes('interceptedManifests.push')) {
|
|
throw new Error('Content script does not collect intercepted manifests');
|
|
}
|
|
});
|
|
|
|
it('should respond to GOON_GET_VIDEO_DATA with intercepted manifests', () => {
|
|
if (!contentSource.includes("message.type === 'GOON_GET_VIDEO_DATA'")) {
|
|
throw new Error('Content script missing GOON_GET_VIDEO_DATA message handler');
|
|
}
|
|
if (!contentSource.includes('interceptedManifests: interceptedManifests')) {
|
|
throw new Error('Content script does not return intercepted manifests in response');
|
|
}
|
|
});
|
|
|
|
it('should filter postMessage from non-self sources', () => {
|
|
if (!contentSource.includes('event.source !== window')) {
|
|
throw new Error('Content script does not filter iframe/cross-origin postMessages');
|
|
}
|
|
});
|
|
|
|
it('should inject interceptors via script.src (not inline textContent)', () => {
|
|
// Verify content.js uses script.src = chrome.runtime.getURL(...) pattern
|
|
// This is the CSP-safe approach vs inline script.textContent
|
|
const injectFn = contentSource.match(/function injectInterceptors\(\)[\s\S]*?\n\}/);
|
|
if (!injectFn) throw new Error('injectInterceptors function not found');
|
|
|
|
if (!injectFn[0].includes('script.src = chrome.runtime.getURL')) {
|
|
throw new Error('Content script does not use script.src = chrome.runtime.getURL for injection');
|
|
}
|
|
|
|
// Verify it does NOT set script.textContent (which CSP blocks)
|
|
// We check actual assignment, not comments — strip comments first
|
|
const noComments = injectFn[0].replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
|
|
if (noComments.includes('script.textContent')) {
|
|
throw new Error('Content script uses inline script.textContent injection — blocked by CSP');
|
|
}
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Test: Video playback state machine transitions */
|
|
/* ========================================================================== */
|
|
|
|
describe('HLS.js: Playback state machine', () => {
|
|
it('should transition from idle -> loading -> buffering -> playing on normal playback', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_state_machine';
|
|
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
|
|
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
win.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const inst = hlsCalls.instances[0];
|
|
if (!inst) throw new Error('No HLS instance');
|
|
|
|
// 1. MEDIA_ATTACHED -> loading
|
|
inst.events['mediaAttached'][0]('mediaAttached');
|
|
|
|
// 2. MANIFEST_LOADED -> loading
|
|
inst.events['manifestLoaded'][0]('manifestLoaded', { levels: [{ width: 1280, height: 720 }] });
|
|
|
|
// 3. FRAG_LOADING (video.paused=true) -> buffering
|
|
const video = win.document.querySelector('video');
|
|
Object.defineProperty(video, 'paused', { value: true, configurable: true });
|
|
Object.defineProperty(video, 'readyState', { value: 1, configurable: true });
|
|
inst.events['fragLoading'][0]('fragLoading');
|
|
|
|
// 4. FRAG_BUFFERED (video not paused, readyState >= 3) -> playing
|
|
Object.defineProperty(video, 'paused', { value: false, configurable: true });
|
|
Object.defineProperty(video, 'readyState', { value: 4, configurable: true });
|
|
inst.events['fragBuffered'][0]('fragBuffered');
|
|
|
|
const overlay = win.document.querySelector('.panel-overlay');
|
|
if (!overlay) throw new Error('No panel overlay found');
|
|
if (!overlay.textContent.includes('playing')) {
|
|
throw new Error(`Expected overlay to show 'playing' state, got: ${overlay.textContent}`);
|
|
}
|
|
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should transition to switching state during level change', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_level_switch';
|
|
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
|
|
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
win.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const inst = hlsCalls.instances[0];
|
|
const hls = inst.hlsInstance; // the actual mock instance
|
|
|
|
// Set levels on the actual Hls instance (viewer.js reads hls.levels)
|
|
// The mock instance is the one returned by the MockHLS constructor
|
|
// We need to set it on the real instance, not our tracking proxy
|
|
const levelSwitchedHandlers = inst.events['levelSwitched'];
|
|
if (!levelSwitchedHandlers) throw new Error('LEVEL_SWITCHED handler not registered');
|
|
|
|
// Simulate level switch
|
|
levelSwitchedHandlers[0]('levelSwitched', { level: 0 });
|
|
|
|
// Check overlay for switching state
|
|
const overlay = win.document.querySelector('.panel-overlay');
|
|
if (!overlay) throw new Error('No panel overlay');
|
|
if (!overlay.textContent.includes('switching')) {
|
|
throw new Error(`Expected 'switching' state during level change, got: ${overlay.textContent}`);
|
|
}
|
|
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should transition from any non-error state to playing on FRAG_BUFFERED', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_direct_playing';
|
|
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
|
|
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
win.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const inst = hlsCalls.instances[0];
|
|
const video = win.document.querySelector('video');
|
|
|
|
Object.defineProperty(video, 'paused', { value: false, configurable: true });
|
|
Object.defineProperty(video, 'readyState', { value: 4, configurable: true });
|
|
|
|
const fragBufferedHandlers = inst.events['fragBuffered'];
|
|
if (!fragBufferedHandlers) throw new Error('FRAG_BUFFERED handler not registered');
|
|
|
|
fragBufferedHandlers[0]('fragBuffered');
|
|
|
|
const overlay = win.document.querySelector('.panel-overlay');
|
|
if (!overlay) throw new Error('No panel overlay');
|
|
if (!overlay.textContent.includes('playing')) {
|
|
throw new Error(`Expected direct transition to 'playing', got: ${overlay.textContent}`);
|
|
}
|
|
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Test: HLS.js error recovery */
|
|
/* ========================================================================== */
|
|
|
|
describe('HLS.js: Error handling and recovery', () => {
|
|
it('should attempt media error recovery (not destroy instance)', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_media_err';
|
|
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
|
|
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
win.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const inst = hlsCalls.instances[0];
|
|
if (!inst) throw new Error('No HLS instance');
|
|
|
|
const errorHandlers = inst.events['error'];
|
|
if (!errorHandlers) throw new Error('ERROR handler not registered');
|
|
|
|
// Simulate non-fatal media error (should recover, not destroy)
|
|
errorHandlers[0]('error', {
|
|
fatal: false,
|
|
type: 'mediaError',
|
|
details: 'fragParsingError',
|
|
});
|
|
|
|
if (inst.destroyed) {
|
|
throw new Error('HLS instance was destroyed on non-fatal media error — should have recovered');
|
|
}
|
|
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should destroy instance on fatal network error', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_net_err';
|
|
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
|
|
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
win.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const inst = hlsCalls.instances[0];
|
|
const errorHandlers = inst.events['error'];
|
|
|
|
errorHandlers[0]('error', {
|
|
fatal: true,
|
|
type: 'networkError',
|
|
details: 'loadsource timeout',
|
|
});
|
|
|
|
if (!inst.destroyed) {
|
|
throw new Error('HLS instance should be destroyed on fatal network error');
|
|
}
|
|
|
|
const panelError = win.document.querySelector('.panel-error');
|
|
if (!panelError) {
|
|
throw new Error('Expected panel-error element for fatal network error');
|
|
}
|
|
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should track non-fatal fragment load failures (segmentsFailed++)', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_frag_fail';
|
|
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
|
|
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
win.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const inst = hlsCalls.instances[0];
|
|
const errorHandlers = inst.events['error'];
|
|
const fragLoadedHandlers = inst.events['fragLoaded'];
|
|
|
|
// First load some segments so the overlay shows segment count
|
|
for (let i = 0; i < 5; i++) {
|
|
fragLoadedHandlers[0]('fragLoaded', { frag: { level: 0, sn: i } });
|
|
}
|
|
|
|
// Simulate non-fatal network errors (transient fragment failures)
|
|
errorHandlers[0]('error', { fatal: false, type: 'networkError', details: 'fragLoadError' });
|
|
errorHandlers[0]('error', { fatal: false, type: 'networkError', details: 'fragLoadError' });
|
|
|
|
const overlay = win.document.querySelector('.panel-overlay');
|
|
if (!overlay) throw new Error('No panel overlay');
|
|
// Should show segment count + failure count
|
|
if (!overlay.textContent.includes('5 seg')) {
|
|
throw new Error(`Expected '5 seg' in overlay after loading segments, got: ${overlay.textContent}`);
|
|
}
|
|
if (!overlay.textContent.includes('fail')) {
|
|
throw new Error(`Expected fragment failure tracking in overlay, got: ${overlay.textContent}`);
|
|
}
|
|
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Test: Manifest URL edge cases (query strings, protocol-relative, etc.) */
|
|
/* ========================================================================== */
|
|
|
|
describe('HLS.js: Manifest URL edge cases', () => {
|
|
it('should intercept .m3u8 URLs with query string parameters', () => {
|
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
|
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 = () => {};
|
|
|
|
win.eval(interceptorSource);
|
|
win.fetch('https://cdn.example.com/stream.m3u8?token=abc123&expires=999999');
|
|
|
|
if (intercepted.length !== 1) {
|
|
throw new Error(`Expected 1 intercept for .m3u8 with query string, got ${intercepted.length}`);
|
|
}
|
|
if (!intercepted[0].url.includes('token=abc123')) {
|
|
throw new Error(`Query string should be preserved: ${intercepted[0].url}`);
|
|
}
|
|
});
|
|
|
|
it('should intercept protocol-relative manifest URLs (//cdn.example.com)', () => {
|
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
|
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 = () => {};
|
|
|
|
win.eval(interceptorSource);
|
|
win.fetch('//cdn.example.com/stream.m3u8');
|
|
|
|
if (intercepted.length !== 1) {
|
|
throw new Error(`Expected 1 intercept for protocol-relative URL, got ${intercepted.length}`);
|
|
}
|
|
if (!intercepted[0].url.startsWith('https://')) {
|
|
throw new Error(`Protocol-relative URL should be resolved to absolute: ${intercepted[0].url}`);
|
|
}
|
|
});
|
|
|
|
it('should intercept .hlsvod URLs (non-standard HLS extension)', () => {
|
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
|
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 = () => {};
|
|
|
|
win.eval(interceptorSource);
|
|
win.fetch('https://cdn.example.com/stream.hlsvod');
|
|
|
|
if (intercepted.length !== 1) {
|
|
throw new Error(`Expected 1 intercept for .hlsvod URL, got ${intercepted.length}`);
|
|
}
|
|
});
|
|
|
|
it('should intercept /hls/ path URLs', () => {
|
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
|
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 = () => {};
|
|
|
|
win.eval(interceptorSource);
|
|
win.fetch('https://cdn.example.com/hls/video/12345/master');
|
|
|
|
if (intercepted.length !== 1) {
|
|
throw new Error(`Expected 1 intercept for /hls/ path URL, got ${intercepted.length}`);
|
|
}
|
|
});
|
|
|
|
it('should use hlsManifest field even when src is not .m3u8', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_hls_field_priority';
|
|
storageData[viewerId] = [{
|
|
url: 'https://example.com/video/123',
|
|
src: 'https://example.com/video/123',
|
|
hlsManifest: 'https://cdn.example.com/stream.m3u8',
|
|
mediaType: 'hls',
|
|
}];
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
|
|
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
win.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const inst = hlsCalls.instances[0];
|
|
if (!inst) throw new Error('No HLS instance — hlsManifest field not used');
|
|
if (!inst.source || !inst.source.includes('stream.m3u8')) {
|
|
throw new Error(`Expected stream.m3u8 from hlsManifest, got: ${inst.source}`);
|
|
}
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Test: Deep probe deduplication and priority */
|
|
/* ========================================================================== */
|
|
|
|
describe('HLS.js: Deep probe deduplication', () => {
|
|
const probeMatch = indexSource.match(/function m3u8DeepProbeFn\(\)[\s\S]*?^}/m);
|
|
if (!probeMatch) throw new Error('Could not find m3u8DeepProbeFn in index.js');
|
|
const probeFnSource = probeMatch[0];
|
|
|
|
function runProbeInWin(win) {
|
|
const result = win.eval(`(${probeFnSource})();`);
|
|
if (!Array.isArray(result)) throw new Error(`m3u8DeepProbeFn did not return an array`);
|
|
return result;
|
|
}
|
|
|
|
it('should deduplicate manifest found via multiple strategies', () => {
|
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
|
url: 'https://example.com/watch',
|
|
runScripts: 'dangerously',
|
|
});
|
|
const win = dom.window;
|
|
|
|
// Same URL via multiple detection strategies
|
|
win.hls = { url: 'https://cdn.example.com/stream.m3u8' };
|
|
win.__goon_plugin_data = {
|
|
manifests: ['https://cdn.example.com/stream.m3u8'],
|
|
sources: ['xhr'],
|
|
};
|
|
const script = win.document.createElement('script');
|
|
script.textContent = 'var m = "https://cdn.example.com/stream.m3u8";';
|
|
win.document.body.appendChild(script);
|
|
|
|
const results = runProbeInWin(win);
|
|
|
|
const duplicates = results.filter(r => r.url === 'https://cdn.example.com/stream.m3u8');
|
|
if (duplicates.length > 1) {
|
|
throw new Error(`Expected deduplication: manifest found ${duplicates.length} times across strategies`);
|
|
}
|
|
if (duplicates.length !== 1) {
|
|
throw new Error(`Manifest not found. Results: ${JSON.stringify(results)}`);
|
|
}
|
|
});
|
|
|
|
it('should deduplicate via interceptor strategy (seen Set prevents duplicates)', () => {
|
|
// The seen Set in m3u8DeepProbeFn ensures that once a URL is found by any
|
|
// earlier strategy, later strategies skip it. Strategy order: A(player globals),
|
|
// B(video elements), C(inline scripts), D(config globals), E(__goon_plugin_data).
|
|
// When __goon_plugin_data is the ONLY source, it should be the player label.
|
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
|
url: 'https://example.com/watch',
|
|
runScripts: 'dangerously',
|
|
});
|
|
const win = dom.window;
|
|
|
|
// Only interceptor-collected data (Strategy E)
|
|
win.__goon_plugin_data = {
|
|
manifests: ['https://cdn.example.com/stream.m3u8'],
|
|
sources: ['xhr'],
|
|
};
|
|
|
|
const results = runProbeInWin(win);
|
|
const found = results.find(r => r.url === 'https://cdn.example.com/stream.m3u8');
|
|
if (!found) throw new Error('Manifest not found');
|
|
if (found.player !== '__goon_plugin_data') {
|
|
throw new Error(`Expected player=__goon_plugin_data, got: ${found.player}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Test: dedupManifests priority sorting */
|
|
/* ========================================================================== */
|
|
|
|
describe('HLS.js: Manifest priority sorting', () => {
|
|
it('should sort manifests by priority: interceptor > player > inline-script', () => {
|
|
// Extract dedupManifests from index.js
|
|
const dedupMatch = indexSource.match(/function dedupManifests\(allManifests\)[\s\S]*?^}/m);
|
|
if (!dedupMatch) throw new Error('Could not find dedupManifests in index.js');
|
|
|
|
// Check playerRank values enforce correct priority
|
|
const rankMatch = indexSource.match(/const playerRank = \{[\s\S]*?\}/);
|
|
if (!rankMatch) throw new Error('Could not find playerRank in index.js');
|
|
|
|
const rankText = rankMatch[0];
|
|
// __goon_plugin_data should have lowest rank number (highest priority)
|
|
if (!rankText.includes("'__goon_plugin_data': 0")) {
|
|
throw new Error('__goon_plugin_data should have rank 0 (highest priority)');
|
|
}
|
|
// inline-script should have highest rank number (lowest priority)
|
|
if (!rankText.includes("'inline-script': 99")) {
|
|
throw new Error('inline-script should have rank 99 (lowest priority)');
|
|
}
|
|
});
|
|
|
|
it('should filter out .ts segment URLs, keeping only manifests', () => {
|
|
const filterMatch = indexSource.match(/function filterManifests\(manifests\)[\s\S]*?^}/m);
|
|
if (!filterMatch) throw new Error('Could not find filterManifests in index.js');
|
|
|
|
const filterText = filterMatch[0];
|
|
// Should filter by .m3u8 / .hlsvod extensions
|
|
if (!filterText.includes('.m3u8') || !filterText.includes('.hlsvod')) {
|
|
throw new Error('filterManifests should check for .m3u8 and .hlsvod extensions');
|
|
}
|
|
// Should handle .ts segment URLs separately
|
|
if (!filterText.includes('.ts')) {
|
|
throw new Error('filterManifests should handle .ts segment URLs');
|
|
}
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Test: End-to-end interceptor -> deep probe pipeline */
|
|
/* ========================================================================== */
|
|
|
|
describe('HLS.js: End-to-end detection pipeline', () => {
|
|
it('should collect intercepted manifests and pass them to deep probe', () => {
|
|
// Pipeline verification:
|
|
// 1. interceptors.js -> window.__goon_plugin_data.manifests[]
|
|
if (!interceptorSource.includes('window.__goon_plugin_data.manifests')) {
|
|
throw new Error('interceptors.js does not write to __goon_plugin_data.manifests');
|
|
}
|
|
// 2. m3u8DeepProbeFn reads from __goon_plugin_data.manifests
|
|
if (!indexSource.includes('window.__goon_plugin_data.manifests')) {
|
|
throw new Error('m3u8DeepProbeFn does not read from __goon_plugin_data.manifests');
|
|
}
|
|
// 3. content.js collects from postMessage
|
|
if (!contentSource.includes('interceptedManifests')) {
|
|
throw new Error('content.js does not track intercepted manifests');
|
|
}
|
|
// 4. viewer.js uses hlsManifest from storage
|
|
if (!viewerSource.includes('hlsManifest')) {
|
|
throw new Error('viewer.js does not use hlsManifest field');
|
|
}
|
|
});
|
|
|
|
it('should handle the full hls.js lifecycle: create -> play -> error -> recover -> destroy', () => {
|
|
const lifecycleSteps = [
|
|
{ step: 'create HLS instance', pattern: /new window\.Hls\(HLS_CONFIG\)/ },
|
|
{ step: 'loadSource', pattern: /hls\.loadSource/ },
|
|
{ step: 'attachMedia', pattern: /hls\.attachMedia/ },
|
|
{ step: 'wireHlsEvents', pattern: /wireHlsEvents\(hls/ },
|
|
{ step: 'wireVideoEvents', pattern: /wireVideoEvents\(video\)/ },
|
|
{ step: 'startBufferMonitor', pattern: /startBufferMonitor\(video/ },
|
|
{ step: 'destroy on error', pattern: /hls\.destroy\(\)/ },
|
|
{ step: 'recoverMediaError', pattern: /hls\.recoverMediaError\(\)/ },
|
|
{ step: 'destroy on unload', pattern: /destroyHls/ },
|
|
];
|
|
|
|
for (const { step, pattern } of lifecycleSteps) {
|
|
if (!pattern.test(viewerSource)) {
|
|
throw new Error(`Missing lifecycle step: ${step}`);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Test: Fragment tracking accuracy */
|
|
/* ========================================================================== */
|
|
|
|
describe('HLS.js: Fragment tracking accuracy', () => {
|
|
it('should correctly count segments loaded via FRAG_LOADED events', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_seg_count';
|
|
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
|
|
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
win.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const inst = hlsCalls.instances[0];
|
|
const fragLoadedHandlers = inst.events['fragLoaded'];
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
fragLoadedHandlers[0]('fragLoaded', { frag: { level: 0, sn: i } });
|
|
}
|
|
|
|
const overlay = win.document.querySelector('.panel-overlay');
|
|
if (!overlay) throw new Error('No panel overlay');
|
|
if (!overlay.textContent.includes('5 seg')) {
|
|
throw new Error(`Expected '5 seg' in overlay, got: ${overlay.textContent}`);
|
|
}
|
|
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should reset segment count on BUFFER_RESET event', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_buf_reset';
|
|
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
|
|
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
win.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const inst = hlsCalls.instances[0];
|
|
|
|
// Load some segments first
|
|
for (let i = 0; i < 3; i++) {
|
|
inst.events['fragLoaded'][0]('fragLoaded', { frag: { level: 0, sn: i } });
|
|
}
|
|
|
|
// Trigger BUFFER_RESET
|
|
inst.events['bufferReset'][0]('bufferReset');
|
|
|
|
const overlay = win.document.querySelector('.panel-overlay');
|
|
if (!overlay) throw new Error('No panel overlay');
|
|
// After buffer reset, segmentsLoaded should be 0
|
|
if (overlay.textContent.includes('3 seg')) {
|
|
throw new Error(`Expected segment count to reset after BUFFER_RESET, still shows '3 seg'`);
|
|
}
|
|
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should track buffer health updates via BUFFER_APPENDED', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_buf_append';
|
|
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
|
|
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
win.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const inst = hlsCalls.instances[0];
|
|
|
|
inst.events['bufferAppended'][0]('bufferAppended');
|
|
|
|
const overlay = win.document.querySelector('.panel-overlay');
|
|
if (!overlay) throw new Error('No panel overlay');
|
|
if (!overlay.textContent.includes('buf')) {
|
|
throw new Error(`Expected buffer info in overlay, got: ${overlay.textContent}`);
|
|
}
|
|
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
});
|