fix: vendor hls.js locally (CSP) and handle null executeScript results
CI / test (pull_request) Failing after 24s
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.
This commit is contained in:
1 parent
b19035ad29
commit
a1f4d2241b
7 files changed
+975
-16
No files matched your search
@@ -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 = `<!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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -903,15 +903,26 @@ describe('HLS QA: Configuration and resource management', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should pin HLS CDN version (not use floating/latest)', () => {
|
||||
const cdnMatch = viewerSource.match(/const HLS_CDN = '([^']+)'/);
|
||||
if (!cdnMatch) throw new Error('HLS_CDN not found');
|
||||
const cdn = cdnMatch[1];
|
||||
if (!cdn.includes('@1.')) {
|
||||
throw new Error(`HLS_CDN should pin to specific version, got: ${cdn}`);
|
||||
it('should vendor HLS bundle locally (not use CDN — avoids CSP blocks)', () => {
|
||||
// Viewer loads hls.js from extension origin via chrome.runtime.getURL('hls.min.js')
|
||||
// instead of a CDN — sites like Pornhub restrict script-src to 'self', blocking
|
||||
// cross-origin <script src> injection from CDN URLs.
|
||||
if (!viewerSource.includes("chrome.runtime.getURL('hls.min.js')")) {
|
||||
throw new Error('viewer.js should load hls.min.js from extension origin, not CDN');
|
||||
}
|
||||
if (!cdn.includes('.17')) {
|
||||
throw new Error(`HLS_CDN version should match package.json dependency: ${cdn}`);
|
||||
// Verify no CDN references remain (cdn.jsdelivr, unpkg, etc.)
|
||||
const cdnRefs = viewerSource.match(/https?:\/\/(cdn\.jsdelivr|unpkg|cdnjs)/g);
|
||||
if (cdnRefs) {
|
||||
throw new Error(`viewer.js should not reference CDN URLs (found: ${cdnRefs.join(', ')})`);
|
||||
}
|
||||
// Verify the vendored bundle exists in dist
|
||||
const hlsPath = path.join(__dirname, '../dist/hls.js');
|
||||
if (!fs.existsSync(hlsPath)) {
|
||||
throw new Error('dist/hls.js not found — run npm run build');
|
||||
}
|
||||
const hlsSize = fs.statSync(hlsPath).size;
|
||||
if (hlsSize < 200_000) {
|
||||
throw new Error(`dist/hls.js too small (${hlsSize} bytes) — bundle may be empty`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["interceptors.js"],
|
||||
"resources": ["interceptors.js", "hls.min.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
!function t(e){var r,i;r=this,i=function(){"use strict";function r(t,e){var r=Object.keys(t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);e&&(i=i.filter((function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable}))),r.push.apply(r,i)}return r}function i(t){for(var e=1;e<arguments.length;e++){var i=null!=arguments[e]?arguments[e]:{};e%2?r(Object(i),!0).forEach((function(e){var r,a,s;r=t,a=e,s=i[e],(a=n(a))in r?Object.defineProperty(r,a,{value:s,enumerable:!0,configurable:!0,writable:!0}):r[a]=s})):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(i)):r(Object(i)).forEach((function(e){Object.defineProperty(t,e,Object.getOwnPropertyDescriptor(i,e))}))}return t}function n(t){var e=function(t,e){if("object"!=typeof t||!t)return t;var r=t[Symbol.toPrimitive];if(void 0!==r){var i=r.call(t,e||"default");if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===e?String:Number)(t)}(t,"string");return"symbol"==typeof e?e:String(e)}function a(t,e){for(var r=0;r<e.length;r++){var i=e[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(t,n(i.key),i)}}function s(t,e,r){return e&&a(t.prototype,e),r&&a(t,r),Object.defineProperty(t,"prototype",{writable:!1}),t}function o(){return o=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var r=arguments[e];for(var i in r)Object.prototype.hasOwnProperty.call(r,i)&&(t[i]=r[i])}return t},o.apply(this,arguments)}function l(t,e){t.prototype=Object.create(e.prototype),t.prototype.constructor=t,h(t,e)}function u(t){return u=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(t){return t.__proto__||Object.getPrototypeOf(t)},u(t)}function h(t,e){return h=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,e){return t.__proto__=e,t},h(t,e)}function d(t,e,r){return d=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}()?Reflect.construct.bind():function(t,e,r){var i=[null];i.push.apply(i,e);var n=new(Function.bind.apply(t,i));return r&&h(n,r.prototype),n},d.apply(null,arguments)}function c(t){var e="function"==typeof Map?new Map:void 0;return c=function(t){if(null===t||!function(t){try{return-1!==Function.toString.call(t).indexOf("[native code]")}catch(e){return"function"==typeof t}}(t))return t;if("function"!=typeof t)throw new TypeError("Super expression must either be null or a function");if(void 0!==e){if(e.has(t))return e.get(t);e.set(t,r)}function r(){return d(t,arguments,u(this).constructor)}return r.prototype=Object.create(t.prototype,{constructor:{value:r,enumerable:!1,writable:!0,configurable:!0}}),h(r,t)},c(t)}function f(t,e){(null==e||e>t.length)&&(e=t.length);for(var r=0,i=new Array(e);r<e;r++)i[r]=t[r];return i}function g(t,e){var r="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(r)return(r=r.call(t)).next.bind(r);if(Array.isArray(t)||(r=function(t,e){if(t){if("string"==typeof t)return f(t,e);var r=Object.prototype.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?f(t,e):void 0}}(t))||e&&t&&"number"==typeof t.length){r&&(t=r);var i=0;return function(){return i>=t.length?{done:!0}:{done:!1,value:t[i++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function v(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var m={exports:{}};!function(t,e){var r,i,n,a,s;r=/^(?=((?:[a-zA-Z0-9+\-.]+:)?))\1(?=((?:\/\/[^\/?#]*)?))\2(?=((?:(?:[^?#\/]*\/)*[^;?#\/]*)?))\3((?:;[^?#]*)?)(\?[^#]*)?(#[^]*)?$/,i=/^(?=([^\/?#]*))\1([^]*)$/,n=/(?:\/|^)\.(?=\/)/g,a=/(?:\/|^)\.\.\/(?!\.\.\/)[^\/]*(?=\/)/g,s={buildAbsoluteURL:function(t,e,r){if(r=r||{},t=t.trim(),!(e=e.trim())){if(!r.alwaysNormalize)return t;var n=s.parseURL(t);if(!n)throw new Error("Error trying to parse base URL.");return n.path=s.normalizePath(n.path),s.buildURLFromParts(n)}var a=s.parseURL(e);if(!a)throw new Error("Error trying to parse relative URL.");if(a.scheme)return r.alwaysNormalize?(a.path=s.normalizePath(a.path),s.buildURLFromParts(a)):e;var o=s.parseURL(t);if(!o)throw new Error("Error trying to parse base URL.");if(!o.netLoc&&o.path&&"/"!==o.path[0]){var l=i.exec(o.path);o.netLoc=l[1],o.path=l[2]}o.netLoc&&!o.path&&(o.path="/");var u={scheme:o.scheme,netLoc:a.netLoc,path:null,params:a.params,query:a.query,fragment:a.fragment};if(!a.netLoc&&(u.netLoc=o.netLoc,"/"!==a.path[0]))if(a.path){var h=o.path,d=h.substring(0,h.lastIndexOf("/")+1)+a.path;u.path=s.normalizePath(d)}else u.path=o.path,a.paramsLine truncated
|
||||
//# sourceMappingURL=hls.min.js.map
|
||||
+10
-3
@@ -391,7 +391,9 @@ async function probeHlsManifests(tabId) {
|
||||
target: { tabId, allFrames: true, world: 'MAIN' },
|
||||
func: m3u8DeepProbeFn,
|
||||
});
|
||||
return results.flatMap(r => r.result || []);
|
||||
// When allFrames is true, destroyed frames may return null entries.
|
||||
// Filter them out before accessing .result to avoid TypeError.
|
||||
return results.filter(r => r && typeof r === 'object').flatMap(r => r.result || []);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -460,11 +462,16 @@ async function scrapeTab(tabId, manifests) {
|
||||
func: scrapeFn,
|
||||
args: [manifests],
|
||||
});
|
||||
// Prefer the result with the largest video (may come from an iframe)
|
||||
// When allFrames is true, destroyed frames may return null entries.
|
||||
// Filter them out before accessing .result to avoid TypeError.
|
||||
const valid = results
|
||||
.filter(r => r && typeof r === 'object')
|
||||
.map(r => r.result)
|
||||
.filter(r => r && r.videos && r.videos.length > 0);
|
||||
if (valid.length === 0) return results[0]?.result || null;
|
||||
if (valid.length === 0) {
|
||||
const firstValid = results.find(r => r && typeof r === 'object');
|
||||
return firstValid?.result || null;
|
||||
}
|
||||
valid.sort((a, b) => {
|
||||
const aArea = a.videos[0]?.area || 0;
|
||||
const bArea = b.videos[0]?.area || 0;
|
||||
|
||||
+10
-4
@@ -19,7 +19,10 @@
|
||||
* overlays and an aggregate status bar.
|
||||
*/
|
||||
|
||||
const HLS_CDN = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.17';
|
||||
// HLS is vendored locally to avoid CSP blocks on sites like Pornhub that
|
||||
// restrict script-src to 'self'. The hls.min.js bundle is built by Vite
|
||||
// and exposed via chrome.runtime.getURL() so it loads from the extension
|
||||
// origin, bypassing page CSP entirely.
|
||||
|
||||
/**
|
||||
* Grid-optimized hls.js configuration.
|
||||
@@ -711,7 +714,10 @@ function playHls(video, manifestUrl, panel) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load hls.js from CDN. Returns a Promise that resolves when ready.
|
||||
* Load hls.js from the extension bundle. Returns a Promise that resolves when ready.
|
||||
* We load from chrome.runtime.getURL() (extension origin) instead of a CDN to
|
||||
* avoid CSP blocks — sites like Pornhub restrict script-src to 'self', which
|
||||
* would block any cross-origin <script src> injection in the viewer window.
|
||||
*/
|
||||
function loadHlsJs() {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -720,9 +726,9 @@ function loadHlsJs() {
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = HLS_CDN;
|
||||
script.src = chrome.runtime.getURL('hls.min.js');
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Hls.js CDN unavailable'));
|
||||
script.onerror = () => reject(new Error('Hls.js bundle unavailable - rebuild the extension.'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export default defineConfig({
|
||||
viewer: resolve(__dirname, 'src/viewer/viewer.html'),
|
||||
viewerIndex: resolve(__dirname, 'src/viewer/index.html'),
|
||||
interceptors: resolve(__dirname, 'src/inject/interceptors.js'),
|
||||
hls: resolve(__dirname, 'src/inject/hls.min.js'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: '[name].js',
|
||||
|
||||
Reference in new issue
Block a user