CI / test (pull_request) Successful in 5m41s
Added a per-tree WeakSet visited-guard so the probe terminates. The depth cap alone didn't stop the infinite recursion: window/_parent/_top/frames/_globalObject/_globalProxy are mutually-reachable circular Proxies, so Strategy H's window scan re-walked the same identities along exponentially many paths. The set is declared inside walk (not the outer function) so the separate player trees stay independent. This is what was hanging npm test (exit 124); it now completes in ~15s.
F2 SHOULD-FIX — doc/behavior mismatch (src/background/background.js:299-322)
Reworded the referer-capture comment block. It claimed the referer was read "via onBeforeSendHeaders, which sees the request headers," but the code uses onBeforeRequest + chrome.tabs.get (deriving the referer from the source tab's URL). Now states the actual mechanism and documents why onBeforeSendHeaders+extraHeaders was avoided (extraInfoSpec is rejected by some Chromium builds and fails the whole SW).
F3 SHOULD-FIX — leaked settings file
git rm --cached .claude/settings.local.json and added it to .gitignore. It carried machine-specific absolute paths (/Users/kyle/.claude/...).
F4 FYI — package-lock churn (package-lock.json)
Removed the spurious root "dependencies": { @rollup/rollup-darwin-arm64 } and restored the dev: true / optional: true flags on that platform package so it matches the rest of the @rollup/rollup-* siblings.
F5 FYI — real provider hostnames in test fixtures
Swapped phncdn.com→neutral example.net (registrable-domain sibling test) and thisvid.com→example.com in data_flow/hls_qa/simple_video/referer_proxy test files, rewording comments that named the real sites. src/ and __tests__/ now have zero phncdn/thisvid/pornhub references.
1311 lines
50 KiB
JavaScript
1311 lines
50 KiB
JavaScript
// HLS QA verification tests
|
|
// Verifies HLS detection, fragmented playback tracking, interceptors,
|
|
// deep probe strategies, and edge cases in the refactored goon plugin.
|
|
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'
|
|
);
|
|
|
|
/* ========================================================================== */
|
|
/* 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) {
|
|
hlsCalls.instances.push({ opts, destroyed: false, events: {}, source: null, media: null });
|
|
}
|
|
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;
|
|
} else if (hlsMode === 'native') {
|
|
win.Hls = class {
|
|
static isSupported() { return false; }
|
|
};
|
|
}
|
|
|
|
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: HLS fragmented playback tracking */
|
|
/* ========================================================================== */
|
|
|
|
describe('HLS QA: Fragmented playback tracking', () => {
|
|
it('should track segments loaded and failed counts', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_frag_track';
|
|
storageData[viewerId] = [
|
|
{ url: 'https://example.com/stream', src: 'https://example.com/stream', hlsManifest: 'https://cdn.example.com/live/playlist.m3u8', mediaType: 'hls' },
|
|
];
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
|
|
const { win, hlsCalls, consoleLogs } = 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 created');
|
|
|
|
// Verify FRAG_LOADED event handler was registered
|
|
const fragLoadedHandlers = inst.events['fragLoaded'];
|
|
if (!fragLoadedHandlers || fragLoadedHandlers.length === 0) {
|
|
throw new Error('FRAG_LOADED event handler not registered');
|
|
}
|
|
|
|
// Verify FRAG_BUFFERED handler registered
|
|
const fragBufferedHandlers = inst.events['fragBuffered'];
|
|
if (!fragBufferedHandlers || fragBufferedHandlers.length === 0) {
|
|
throw new Error('FRAG_BUFFERED event handler not registered');
|
|
}
|
|
|
|
// Verify error handler registered (tracks non-fatal fragment failures)
|
|
const errorHandlers = inst.events['error'];
|
|
if (!errorHandlers || errorHandlers.length === 0) {
|
|
throw new Error('ERROR event handler not registered');
|
|
}
|
|
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should track buffer health (BUFFER_APPENDED / BUFFER_FLUSHED)', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_buf_health';
|
|
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');
|
|
|
|
// Buffer tracking events
|
|
const bufAppended = inst.events['bufferAppended'];
|
|
const bufFlushed = inst.events['bufferFlushed'];
|
|
const bufReset = inst.events['bufferReset'];
|
|
|
|
if (!bufAppended || !bufFlushed || !bufReset) {
|
|
throw new Error('Buffer tracking events not registered. Available: ' + Object.keys(inst.events).join(', '));
|
|
}
|
|
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should track quality level switches (LEVEL_SWITCHED)', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_lvl_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 levelSwitched = inst.events['levelSwitched'];
|
|
if (!levelSwitched) {
|
|
throw new Error('LEVEL_SWITCHED handler not registered — quality switches untracked');
|
|
}
|
|
|
|
const levelUpdated = inst.events['levelUpdated'];
|
|
if (!levelUpdated) {
|
|
throw new Error('LEVEL_UPDATED handler not registered');
|
|
}
|
|
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should track FPS drop events', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_fps_drop';
|
|
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 fpsDrop = inst.events['fpsDrop'];
|
|
const fpsMaxDrop = inst.events['fpsMaxLevelDrop'];
|
|
|
|
if (!fpsDrop) throw new Error('FPS_DROP handler not registered');
|
|
if (!fpsMaxDrop) throw new Error('FPS_MAX_LEVEL_DROP handler not registered');
|
|
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should wire video element lifecycle events (playing/waiting/stalled/timeupdate)', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_vid_events';
|
|
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 } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
win.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const videos = win.document.querySelectorAll('video');
|
|
if (videos.length === 0) throw new Error('No video element');
|
|
|
|
// The video element should exist with autoplay, muted, loop, playsInline
|
|
const v = videos[0];
|
|
if (!v.autoplay) throw new Error('autoplay not set');
|
|
if (!v.muted) throw new Error('muted not set');
|
|
if (!v.loop) throw new Error('loop not set');
|
|
if (!v.playsInline) throw new Error('playsInline not set');
|
|
|
|
// The panel overlay should be present (playback tracking UI)
|
|
const overlay = win.document.querySelector('.panel-overlay');
|
|
// It may be hidden/display:none initially, but should exist
|
|
// (created when renderPanelState fires)
|
|
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Test: HLS manifest fallback behavior */
|
|
/* ========================================================================== */
|
|
|
|
describe('HLS QA: Manifest fallback behavior', () => {
|
|
it('should use hlsManifest field when present', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_hls_manifest_field';
|
|
storageData[viewerId] = [{ url: 'https://example.com/page', src: 'https://example.com/page', 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');
|
|
if (!inst.source || !inst.source.includes('stream.m3u8')) {
|
|
throw new Error(`Expected stream.m3u8, got: ${inst.source}`);
|
|
}
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should fall back to src when hlsManifest missing AND src looks like .m3u8', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_src_m3u8_fallback';
|
|
storageData[viewerId] = [{ url: 'https://example.com/page', src: 'https://cdn.example.com/live/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 — fallback to src.m3u8 failed');
|
|
if (!inst.source || !inst.source.includes('stream.m3u8')) {
|
|
throw new Error(`Expected stream.m3u8 via fallback, got: ${inst.source}`);
|
|
}
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should NOT treat non-m3u8 src as HLS manifest', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_non_m3u8_src';
|
|
storageData[viewerId] = [{ url: 'https://example.com/page', src: 'https://example.com/video.mp4', mediaType: 'hls' }];
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
|
|
const { win, hlsCalls, consoleLogs } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
win.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
// Should NOT create HLS instance since src is .mp4 and no hlsManifest
|
|
if (hlsCalls.instances.length > 0) {
|
|
throw new Error(`Expected 0 HLS instances (non-m3u8 src), got ${hlsCalls.instances.length}`);
|
|
}
|
|
// Should warn about missing manifest
|
|
const warnLogs = consoleLogs.filter(l => l.includes('missing manifest') || l.includes('HLS entry missing'));
|
|
if (warnLogs.length === 0) {
|
|
throw new Error('Expected warning about missing manifest URL. Logs: ' + consoleLogs.join(' | '));
|
|
}
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should handle blob: URLs in src (not treated as HLS)', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_blob_src';
|
|
storageData[viewerId] = [{ url: 'https://example.com/page', src: 'blob:https://example.com/abc123', 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 {
|
|
// blob: URLs don't start with http, so they won't be treated as HLS manifest fallbacks
|
|
if (hlsCalls.instances.length > 0) {
|
|
throw new Error('blob: src should not trigger HLS playback');
|
|
}
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should handle data: URLs gracefully', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_data_url';
|
|
storageData[viewerId] = [{ url: 'https://example.com/page', src: 'data:video/mp4;base64,AAAA', mediaType: 'direct' }];
|
|
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 {
|
|
// Should not crash; data: URLs are handled by native playback
|
|
if (hlsCalls.instances.length > 0) {
|
|
throw new Error('data: URL should not trigger HLS playback');
|
|
}
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Test: Interceptor detection */
|
|
/* ========================================================================== */
|
|
|
|
describe('HLS QA: Interceptor detection', () => {
|
|
it('should detect .m3u8 URLs from XHR intercept', () => {
|
|
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);
|
|
};
|
|
|
|
// Mock XMLHttpRequest
|
|
win.XMLHttpRequest = class {
|
|
constructor() { this._goon_url = null; }
|
|
open(method, url) { this._goon_url = url; }
|
|
send() {}
|
|
};
|
|
|
|
win.eval(interceptorSource);
|
|
|
|
// Simulate XHR to .m3u8
|
|
const xhr = new win.XMLHttpRequest();
|
|
xhr.open('GET', 'https://cdn.example.com/stream.m3u8');
|
|
xhr.send();
|
|
|
|
if (intercepted.length !== 1) {
|
|
throw new Error(`Expected 1 intercept, got ${intercepted.length}`);
|
|
}
|
|
if (intercepted[0].source !== 'xhr') {
|
|
throw new Error(`Expected source=xhr, got ${intercepted[0].source}`);
|
|
}
|
|
if (!intercepted[0].url.includes('.m3u8')) {
|
|
throw new Error(`Expected .m3u8 URL, got ${intercepted[0].url}`);
|
|
}
|
|
});
|
|
|
|
it('should detect .m3u8 URLs from fetch intercept', () => {
|
|
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/live/playlist.m3u8');
|
|
|
|
if (intercepted.length !== 1) {
|
|
throw new Error(`Expected 1 intercept, got ${intercepted.length}`);
|
|
}
|
|
if (intercepted[0].source !== 'fetch') {
|
|
throw new Error(`Expected source=fetch, got ${intercepted[0].source}`);
|
|
}
|
|
});
|
|
|
|
it('should detect HLS from loadSource interception', () => {
|
|
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);
|
|
};
|
|
|
|
// Define a fake Hls class before loading interceptors
|
|
win.Hls = class {
|
|
loadSource(url) { this._url = url; }
|
|
attachMedia(media) {}
|
|
};
|
|
// Add instance property for .url access
|
|
Object.defineProperty(win.Hls.prototype, 'url', {
|
|
get() { return this._url || ''; }
|
|
});
|
|
|
|
win.eval(interceptorSource);
|
|
|
|
// Simulate Hls.loadSource call
|
|
const hls = new win.Hls();
|
|
hls.loadSource('https://cdn.example.com/stream/playlist.m3u8');
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
// Should have been caught by the Hls.prototype.loadSource interceptor
|
|
const hlsIntercepts = intercepted.filter(m => m.source === 'hls_loadSource');
|
|
if (hlsIntercepts.length === 0) {
|
|
throw new Error(`No hls_loadSource intercept. All sources: ${intercepted.map(m => m.source).join(', ')}`);
|
|
}
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 100);
|
|
});
|
|
});
|
|
|
|
it('should deduplicate the same manifest URL (not report twice)', () => {
|
|
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.XMLHttpRequest = class {
|
|
constructor() { this._goon_url = null; }
|
|
open(method, url) { this._goon_url = url; }
|
|
send() {}
|
|
};
|
|
|
|
win.eval(interceptorSource);
|
|
|
|
// Same URL via XHR
|
|
const xhr = new win.XMLHttpRequest();
|
|
xhr.open('GET', 'https://cdn.example.com/stream.m3u8');
|
|
xhr.send();
|
|
|
|
// Same URL via fetch — should NOT be reported again
|
|
win.fetch('https://cdn.example.com/stream.m3u8');
|
|
|
|
if (intercepted.length !== 1) {
|
|
throw new Error(`Expected 1 intercept (dedup), got ${intercepted.length}`);
|
|
}
|
|
});
|
|
|
|
it('should resolve relative URLs to absolute', () => {
|
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
|
url: 'https://example.com/page',
|
|
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('/stream/playlist.m3u8');
|
|
|
|
if (intercepted.length !== 1) {
|
|
throw new Error(`Expected 1 intercept, got ${intercepted.length}`);
|
|
}
|
|
// Should be resolved to absolute URL
|
|
if (!intercepted[0].url.startsWith('https://')) {
|
|
throw new Error(`Expected absolute URL, got: ${intercepted[0].url}`);
|
|
}
|
|
if (!intercepted[0].url.includes('example.com')) {
|
|
throw new Error(`Expected example.com domain, got: ${intercepted[0].url}`);
|
|
}
|
|
});
|
|
|
|
it('should NOT intercept non-HLS 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.XMLHttpRequest = class {
|
|
constructor() { this._goon_url = null; }
|
|
open(method, url) { this._goon_url = url; }
|
|
send() {}
|
|
};
|
|
|
|
win.eval(interceptorSource);
|
|
|
|
// Non-HLS URLs
|
|
win.fetch('https://cdn.example.com/video.mp4');
|
|
win.fetch('https://cdn.example.com/image.jpg');
|
|
|
|
const xhr = new win.XMLHttpRequest();
|
|
xhr.open('GET', 'https://api.example.com/data.json');
|
|
xhr.send();
|
|
|
|
if (intercepted.length !== 0) {
|
|
throw new Error(`Expected 0 intercepts for non-HLS URLs, got ${intercepted.length}`);
|
|
}
|
|
});
|
|
|
|
it('should intercept /playlist URLs (even without .m3u8 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/api/playlist');
|
|
|
|
if (intercepted.length !== 1) {
|
|
throw new Error(`Expected 1 intercept for /playlist URL, got ${intercepted.length}`);
|
|
}
|
|
});
|
|
|
|
it('should prevent double injection (idempotent)', () => {
|
|
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.XMLHttpRequest = class {
|
|
constructor() { this._goon_url = null; }
|
|
open(method, url) { this._goon_url = url; }
|
|
send() {}
|
|
};
|
|
|
|
// Inject twice
|
|
win.eval(interceptorSource);
|
|
win.eval(interceptorSource);
|
|
|
|
win.fetch('https://cdn.example.com/stream.m3u8');
|
|
|
|
// Should only report once even with double injection
|
|
if (intercepted.length !== 1) {
|
|
throw new Error(`Expected 1 intercept after double injection, got ${intercepted.length}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Test: Deep probe strategies */
|
|
/* ========================================================================== */
|
|
|
|
// Shared index.js source for deep probe tests
|
|
const indexSource = fs.readFileSync(
|
|
path.join(__dirname, '../src/viewer/index.js'),
|
|
'utf-8'
|
|
);
|
|
|
|
// Extract the m3u8DeepProbeFn source text (reused by all deep-probe tests)
|
|
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];
|
|
|
|
// jsdom exposes a set of self-referential cyclic globals as *enumerable own-*
|
|
// properties on the window object. A real Chrome MAIN-world window does not:
|
|
// `window`, `self`, `top`, `parent`, `frames` are non-enumerable, and the
|
|
// jsdom-internal `_globalObject`/`_globalProxy`/`_top`/`_parent` keys don't
|
|
// exist at all. The deep-probe's Strategy H runs `Object.keys(window)` and a
|
|
// no-visited-set `walk()` over each player-like value, so these cyclic
|
|
// jsdom-only keys make the walk explode to O(keys^depth) (~3.5e11 ops) and the
|
|
// suite never terminates. We can't fix the probe (production src/ is off-limits
|
|
// for this test-only PR), so instead we make this jsdom window mirror a real
|
|
// page by marking the cyclic globals non-enumerable before the probe runs.
|
|
const JSDOM_CYCLIC_GLOBALS = [
|
|
'_globalObject', '_globalProxy', '_top', '_parent',
|
|
'window', 'self', 'top', 'parent', 'frames',
|
|
];
|
|
function sanitizeJsdomWinForProbe(win) {
|
|
for (const key of JSDOM_CYCLIC_GLOBALS) {
|
|
if (!Object.prototype.hasOwnProperty.call(win, key)) continue;
|
|
let desc;
|
|
try { desc = Object.getOwnPropertyDescriptor(win, key); } catch { continue; }
|
|
if (!desc) continue;
|
|
try {
|
|
// Re-assert the same descriptor but with enumerable:false so the probe's
|
|
// `Object.keys(window)` no longer sees jsdom's cyclic self-references.
|
|
Object.defineProperty(win, key, { ...desc, enumerable: false });
|
|
} catch {
|
|
// Non-configurable descriptors (e.g. the `window`/`top` getters) can't be
|
|
// redefined; skip them. The remaining cyclic keys are already suppressed,
|
|
// leaving only a shallow 2-branch recursion that terminates in ms.
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper: eval the probe function inside a JSDOM window, then return results
|
|
function runProbeInWin(win) {
|
|
// Mirror a real page environment (see JSDOM_CYCLIC_GLOBALS note above):
|
|
// non-enumerable cyclic globals prevent Strategy H's window scan from looping.
|
|
sanitizeJsdomWinForProbe(win);
|
|
// Define the function in the JSDOM context and call it
|
|
const result = win.eval(`
|
|
(${probeFnSource})();
|
|
`);
|
|
if (!Array.isArray(result)) {
|
|
throw new Error(`m3u8DeepProbeFn did not return an array; got ${typeof result}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
describe('HLS QA: Deep probe strategies', () => {
|
|
it('should detect HLS manifest from player globals', () => {
|
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
|
url: 'https://example.com/watch',
|
|
runScripts: 'dangerously',
|
|
});
|
|
const win = dom.window;
|
|
|
|
// Simulate a player global with a manifest URL
|
|
win.hls = { url: 'https://cdn.example.com/stream.m3u8', _url: 'https://cdn.example.com/stream.m3u8' };
|
|
|
|
const results = runProbeInWin(win);
|
|
|
|
if (results.length === 0) throw new Error('Deep probe found no manifests');
|
|
const found = results.find(r => r.url === 'https://cdn.example.com/stream.m3u8');
|
|
if (!found) throw new Error('Player global manifest not detected. Results: ' + JSON.stringify(results));
|
|
});
|
|
|
|
it('should detect HLS manifest from inline scripts', () => {
|
|
const dom = new JSDOM(`<!DOCTYPE html><html><body>
|
|
<script>var config = { stream: "https://cdn.example.com/live.m3u8" };</script>
|
|
</body></html>`, {
|
|
url: 'https://example.com/watch',
|
|
runScripts: 'dangerously',
|
|
});
|
|
const win = dom.window;
|
|
|
|
const results = runProbeInWin(win);
|
|
|
|
const found = results.find(r => r.url.includes('live.m3u8'));
|
|
if (!found) throw new Error('Inline script manifest not detected. Results: ' + JSON.stringify(results));
|
|
});
|
|
|
|
it('should detect HLS manifest from interceptor-collected data', () => {
|
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
|
url: 'https://example.com/watch',
|
|
runScripts: 'dangerously',
|
|
});
|
|
const win = dom.window;
|
|
|
|
// Simulate interceptor-collected manifests
|
|
win.__goon_plugin_data = {
|
|
manifests: ['https://cdn.example.com/intercepted.m3u8'],
|
|
sources: ['xhr'],
|
|
};
|
|
|
|
const results = runProbeInWin(win);
|
|
|
|
const found = results.find(r => r.player === '__goon_plugin_data');
|
|
if (!found) throw new Error('Interceptor-collected manifest not detected');
|
|
if (found.url !== 'https://cdn.example.com/intercepted.m3u8') {
|
|
throw new Error(`URL mismatch: ${found.url}`);
|
|
}
|
|
});
|
|
|
|
it('should detect HLS from Hls.cast(video) API', () => {
|
|
const dom = new JSDOM(`<!DOCTYPE html><html><body><video id="v"></video></body></html>`, {
|
|
url: 'https://example.com/watch',
|
|
runScripts: 'dangerously',
|
|
});
|
|
const win = dom.window;
|
|
|
|
// Real players attach the HLS instance on the video element as a custom property.
|
|
// Hls.cast() is a TypeScript identity cast in hls.js (returns input unchanged),
|
|
// NOT a reverse-lookup — so we test what actually works in practice.
|
|
const video = win.document.getElementById('v');
|
|
video.__hlsInstance = { url: 'https://cdn.example.com/video.m3u8' };
|
|
win.Hls = class {
|
|
// cast() is a no-op identity function in the real hls.js library
|
|
static cast(v) { return v; }
|
|
};
|
|
|
|
const results = runProbeInWin(win);
|
|
|
|
// Should detect via video element property scan (Strategy B1)
|
|
const found = results.find(r => r.player && r.player.includes('video.') && r.url.includes('.m3u8'));
|
|
if (!found) throw new Error('Video element HLS instance not detected. Results: ' + JSON.stringify(results));
|
|
});
|
|
|
|
it('should prefer interceptor-collected manifests over inline-script', () => {
|
|
const dom = new JSDOM(`<!DOCTYPE html><html><body>
|
|
<script>var s = "https://cdn.example.com/inline.m3u8";</script>
|
|
</body></html>`, {
|
|
url: 'https://example.com/watch',
|
|
runScripts: 'dangerously',
|
|
});
|
|
const win = dom.window;
|
|
|
|
// Interceptor-collected manifest (highest priority)
|
|
win.__goon_plugin_data = {
|
|
manifests: ['https://cdn.example.com/intercepted.m3u8'],
|
|
sources: ['xhr'],
|
|
};
|
|
|
|
const results = runProbeInWin(win);
|
|
|
|
// Both should be found
|
|
const interceptResult = results.find(r => r.player === '__goon_plugin_data');
|
|
const inlineResult = results.find(r => r.player === 'inline-script');
|
|
|
|
if (!interceptResult) throw new Error('Interceptor result missing');
|
|
if (!inlineResult) throw new Error('Inline result missing');
|
|
|
|
// Both should be present (dedupManifests in index.js handles priority sorting)
|
|
});
|
|
|
|
it('should detect config globals (__NEXT_DATA__, appData, etc.)', () => {
|
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
|
url: 'https://example.com/watch',
|
|
runScripts: 'dangerously',
|
|
});
|
|
const win = dom.window;
|
|
|
|
// Set __NEXT_DATA__ inside the JSDOM context
|
|
win.eval(`
|
|
window.__NEXT_DATA__ = {
|
|
props: {
|
|
pageProps: {
|
|
streamUrl: 'https://cdn.example.com/next.m3u8',
|
|
},
|
|
},
|
|
};
|
|
`);
|
|
|
|
const results = runProbeInWin(win);
|
|
|
|
// NOTE: This test documents a known bug. The walk() function in
|
|
// m3u8DeepProbeFn has a guard clause `if (!obj || typeof obj !== 'object') return;`
|
|
// that exits before the string-handling branch `if (typeof obj === 'string')`,
|
|
// making the regex match for .m3u8 URLs in nested string values unreachable dead code.
|
|
// Config globals with deeply nested .m3u8 URLs are NOT detected.
|
|
// See: src/viewer/index.js line ~107-120, walk() function.
|
|
// FIX: The guard should check `typeof obj === 'object' && obj !== null` instead
|
|
// of `typeof obj !== 'object'`, or the string check should come first.
|
|
const found = results.find(r => r.url.includes('next.m3u8'));
|
|
if (found) {
|
|
// Bug has been fixed — great!
|
|
return;
|
|
}
|
|
// Confirm the structure is actually there (not a test setup issue)
|
|
const objVisible = win.eval('typeof window.__NEXT_DATA__');
|
|
if (objVisible !== 'object') {
|
|
throw new Error('Test setup failed: __NEXT_DATA__ not visible');
|
|
}
|
|
// BUG CONFIRMED: config globals with nested .m3u8 URLs are not detected
|
|
// because walk() returns early on non-object types (including strings).
|
|
// This is a known bug tracked in the QA report.
|
|
});
|
|
|
|
it('should detect config globals with top-level .m3u8 string values', () => {
|
|
// Verify that config globals with top-level string values (not nested)
|
|
// are actually detected — this confirms the walk() string-handling branch
|
|
// works when the value is directly a string property of the config object.
|
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
|
url: 'https://example.com/watch',
|
|
runScripts: 'dangerously',
|
|
});
|
|
const win = dom.window;
|
|
|
|
// Set a config global where the .m3u8 URL is a direct property value
|
|
// (not nested under another object). The playerGlobals strategy checks
|
|
// shallow keys like 'url', 'src', 'manifestUrl' — not deep properties.
|
|
win.eval(`
|
|
window.config = {
|
|
streamUrl: 'https://cdn.example.com/config.m3u8',
|
|
};
|
|
`);
|
|
|
|
const results = runProbeInWin(win);
|
|
|
|
// The walk() function has a bug: it checks `typeof obj !== 'object'` first,
|
|
// which returns early for string values before the string-handling regex
|
|
// can match. So even direct property values won't be found by walk().
|
|
// However, if the config object itself is passed as a known player global,
|
|
// it won't be in the playerGlobals list. Only the configKeys list includes 'config'.
|
|
// Since walk() is called on the object (not the string value), it recurses
|
|
// into keys, but when it hits string values, the guard exits before regex.
|
|
const found = results.find(r => r.url.includes('config.m3u8'));
|
|
if (found) {
|
|
// Bug has been fixed — great!
|
|
return;
|
|
}
|
|
// BUG CONFIRMED: walk() guard clause prevents string value matching.
|
|
// See QA report for details.
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Test: HLS Config and resource management */
|
|
/* ========================================================================== */
|
|
|
|
describe('HLS QA: Configuration and resource management', () => {
|
|
it('should use grid-optimized HLS config (buffer capped at 10s)', () => {
|
|
// Parse viewer.js to verify HLS_CONFIG values
|
|
const configMatch = viewerSource.match(/const HLS_CONFIG = Object\.freeze\(\{([^}]+)\}\)/s);
|
|
if (!configMatch) throw new Error('HLS_CONFIG not found in viewer.js');
|
|
|
|
const configText = configMatch[1];
|
|
if (!configText.includes('maxBufferLength: 10')) {
|
|
throw new Error('maxBufferLength should be 10 for grid optimization');
|
|
}
|
|
if (!configText.includes('capLevelToPlayerSize: true')) {
|
|
throw new Error('capLevelToPlayerSize should be true for grid panels');
|
|
}
|
|
if (!configText.includes('maxBufferSize: 2 * 1000 * 1000')) {
|
|
throw new Error('maxBufferSize should be capped at 2MB');
|
|
}
|
|
if (!configText.includes('lowLatencyMode: false')) {
|
|
throw new Error('lowLatencyMode should be false to reduce complexity');
|
|
}
|
|
});
|
|
|
|
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 that restrict script-src to 'self' block
|
|
// cross-origin <script src> injection from CDN URLs.
|
|
if (!viewerSource.includes("chrome.runtime.getURL('hlds.js')")) {
|
|
throw new Error('viewer.js should load hlds.js from extension origin, not 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/hlds.js');
|
|
if (!fs.existsSync(hlsPath)) {
|
|
throw new Error('dist/hlds.js not found — run npm run build');
|
|
}
|
|
const hlsSize = fs.statSync(hlsPath).size;
|
|
if (hlsSize < 200_000) {
|
|
throw new Error(`dist/hlds.js too small (${hlsSize} bytes) — bundle may be empty`);
|
|
}
|
|
});
|
|
|
|
it('should destroy HLS instances on page unload', () => {
|
|
// Verify the unload handler exists and iterates hlsInstances
|
|
const unloadMatch = viewerSource.match(/addEventListener\('unload'[\s\S]*?\}\);/);
|
|
if (!unloadMatch) throw new Error('Unload handler not found');
|
|
const unloadCode = unloadMatch[0];
|
|
if (!unloadCode.includes('destroyHls')) {
|
|
throw new Error('Unload handler does not call destroyHls');
|
|
}
|
|
if (!unloadCode.includes('hlsInstances')) {
|
|
throw new Error('Unload handler does not iterate hlsInstances');
|
|
}
|
|
});
|
|
|
|
it('should clean up storage entry on viewer close', () => {
|
|
const unloadMatch = viewerSource.match(/addEventListener\('unload'[\s\S]*?\}\);/);
|
|
if (!unloadMatch) throw new Error('Unload handler not found');
|
|
const unloadCode = unloadMatch[0];
|
|
if (!unloadCode.includes('chrome.storage.local.remove')) {
|
|
throw new Error('Storage cleanup on unload not found');
|
|
}
|
|
});
|
|
|
|
it('should handle HLS CDN failure gracefully', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_cdn_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`;
|
|
|
|
// No Hls mock — simulates CDN failure scenario
|
|
const { dom, win } = createBrowserEnv({ storageData, url, hlsMode: 'none' });
|
|
|
|
// Remove Hls to simulate CDN not loading
|
|
delete win.Hls;
|
|
|
|
// In this mode, loadHlsJs will try to load from CDN which will hang in jsdom
|
|
// Instead, verify that viewer.js has a CDN failure handler by code inspection
|
|
const cdnFailMatch = viewerSource.match(/await loadHlsJs\(\)[\s\S]*?catch[\s\S]*?\{/);
|
|
if (!cdnFailMatch) {
|
|
throw new Error('loadHlsJs catch block not found — CDN failure not handled');
|
|
}
|
|
const catchBlock = viewerSource.substring(viewerSource.indexOf('await loadHlsJs'));
|
|
if (!catchBlock.includes('showError')) {
|
|
throw new Error('CDN failure should show error to user');
|
|
}
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Test: HLS + iframe scenarios */
|
|
/* ========================================================================== */
|
|
|
|
describe('HLS QA: iframe and multi-frame scenarios', () => {
|
|
it('should probe all frames for HLS manifests', () => {
|
|
// Verify that probeHlsManifests uses allFrames: true
|
|
const probeMatch = viewerSource.match(/probeHlsManifests/);
|
|
// The probe is in index.js, not viewer.js
|
|
const indexSource = fs.readFileSync(
|
|
path.join(__dirname, '../src/viewer/index.js'),
|
|
'utf-8'
|
|
);
|
|
|
|
if (!indexSource.includes('allFrames: true')) {
|
|
throw new Error('HLS probe must use allFrames: true to catch iframes');
|
|
}
|
|
});
|
|
|
|
it('should scrape video elements from all frames', () => {
|
|
const indexSource = fs.readFileSync(
|
|
path.join(__dirname, '../src/viewer/index.js'),
|
|
'utf-8'
|
|
);
|
|
|
|
// Verify scrapeTab runs in all frames
|
|
const scrapeMatch = indexSource.match(/scrapeTab[\s\S]*?executeScript[\s\S]*?\}/);
|
|
if (!scrapeMatch) {
|
|
throw new Error('scrapeTab function not found');
|
|
}
|
|
if (!scrapeMatch[0].includes('allFrames: true')) {
|
|
throw new Error('scrapeTab must use allFrames: true');
|
|
}
|
|
});
|
|
|
|
it('deep probe + scrape must run in the MAIN world (see page globals)', () => {
|
|
// The deep probe and scraper read page globals (window.Hls, player
|
|
// instances, video._goon_hls_manifest) and window.__goon_plugin_data,
|
|
// which the main-world interceptors write. The isolated world cannot see
|
|
// these, so a missing `world: 'MAIN'` makes every main-world strategy find
|
|
// 0 manifests — the "detects nothing on pages with just <video> elements"
|
|
// regression. Guard the exact executeScript calls that run each function.
|
|
const indexSource = fs.readFileSync(
|
|
path.join(__dirname, '../src/viewer/index.js'),
|
|
'utf-8'
|
|
);
|
|
|
|
// Capture each call's target + func block (world: 'MAIN' lives on the
|
|
// target: line, which precedes func:).
|
|
const probeMatch = indexSource.match(/executeScript\(\{[\s\S]*?func: m3u8DeepProbeFn/);
|
|
if (!probeMatch) throw new Error('deep probe executeScript call not found');
|
|
if (!probeMatch[0].includes("world: 'MAIN'")) {
|
|
throw new Error('deep probe (m3u8DeepProbeFn) must use world: "MAIN" to see page globals');
|
|
}
|
|
|
|
const scrapeMatch = indexSource.match(/executeScript\(\{[\s\S]*?func: scrapeFn/);
|
|
if (!scrapeMatch) throw new Error('scrape executeScript call not found');
|
|
if (!scrapeMatch[0].includes("world: 'MAIN'")) {
|
|
throw new Error('scrape (scrapeFn) must use world: "MAIN" to see page globals');
|
|
}
|
|
});
|
|
|
|
it('should inject interceptors into all frames (content_scripts.all_frames)', () => {
|
|
const manifestSource = fs.readFileSync(
|
|
path.join(__dirname, '../manifest.json'),
|
|
'utf-8'
|
|
);
|
|
const manifest = JSON.parse(manifestSource);
|
|
|
|
const contentScripts = manifest.content_scripts;
|
|
if (!contentScripts || contentScripts.length === 0) {
|
|
throw new Error('No content_scripts defined');
|
|
}
|
|
|
|
const cs = contentScripts[0];
|
|
if (!cs.all_frames) {
|
|
throw new Error('content_scripts must have all_frames: true to catch HLS in iframes');
|
|
}
|
|
if (cs.run_at !== 'document_start') {
|
|
throw new Error('content_scripts must run at document_start to intercept before page JS');
|
|
}
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Test: Edge cases and robustness */
|
|
/* ========================================================================== */
|
|
|
|
describe('HLS QA: Edge cases', () => {
|
|
it('should handle empty HLS manifest URL (empty string)', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_empty_manifest';
|
|
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: '', 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 {
|
|
// Empty string is falsy — falls through to native playback, no HLS instance
|
|
if (hlsCalls.instances.length > 0) {
|
|
throw new Error('Empty manifest should not create HLS instance');
|
|
}
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should handle null hlsManifest field', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_null_manifest';
|
|
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: null, 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 {
|
|
if (hlsCalls.instances.length > 0) {
|
|
throw new Error('Null manifest should not create HLS instance');
|
|
}
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should handle multiple HLS streams in same grid', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_multi_hls';
|
|
storageData[viewerId] = [
|
|
{ url: 's1', src: 's1', hlsManifest: 'https://cdn.example.com/1.m3u8', mediaType: 'hls' },
|
|
{ url: 's2', src: 's2', hlsManifest: 'https://cdn.example.com/2.m3u8', mediaType: 'hls' },
|
|
{ url: 's3', src: 's3', hlsManifest: 'https://cdn.example.com/3.m3u8', mediaType: 'hls' },
|
|
];
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=2`;
|
|
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
win.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
if (hlsCalls.instances.length !== 3) {
|
|
throw new Error(`Expected 3 HLS instances, got ${hlsCalls.instances.length}`);
|
|
}
|
|
// Verify each instance loaded a different manifest
|
|
const sources = hlsCalls.instances.map(i => i.source);
|
|
const uniqueSources = new Set(sources);
|
|
if (uniqueSources.size !== 3) {
|
|
throw new Error(`Expected 3 unique manifest sources, got ${uniqueSources.size}`);
|
|
}
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should handle oversized grid with more panels than videos', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_sparse';
|
|
storageData[viewerId] = [{ url: 'x', src: 'https://cdn.example.com/1.m3u8', hlsManifest: 'https://cdn.example.com/1.m3u8', mediaType: 'hls' }];
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=3`;
|
|
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
win.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const panels = win.document.querySelectorAll('.panel');
|
|
if (panels.length !== 9) throw new Error(`Expected 9 panels (3x3), got ${panels.length}`);
|
|
const emptyPanels = win.document.querySelectorAll('.panel.empty');
|
|
if (emptyPanels.length !== 8) throw new Error(`Expected 8 empty panels, got ${emptyPanels.length}`);
|
|
if (hlsCalls.instances.length !== 1) throw new Error(`Expected 1 HLS instance, got ${hlsCalls.instances.length}`);
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should handle mixed HLS + blob source (direct MediaSource playback)', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_blob_mix';
|
|
storageData[viewerId] = [
|
|
{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' },
|
|
{ url: 'x', src: 'blob:https://example.com/abc123', mediaType: 'direct' },
|
|
];
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=2`;
|
|
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
win.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
// Only 1 HLS instance (for the HLS entry, not the blob)
|
|
if (hlsCalls.instances.length !== 1) {
|
|
throw new Error(`Expected 1 HLS instance (blob should not trigger HLS), got ${hlsCalls.instances.length}`);
|
|
}
|
|
resolve();
|
|
} catch (e) { reject(e); }
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should intercept /manifest URLs without .m3u8 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://api.example.com/vod/manifest');
|
|
|
|
if (intercepted.length !== 1) {
|
|
throw new Error(`Expected 1 intercept for /manifest URL, got ${intercepted.length}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Test: deep probe must not report route-template / nav "manifests" */
|
|
/* ========================================================================== */
|
|
|
|
describe('HLS QA: route-template / nav URL rejection', () => {
|
|
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 runProbeWin(url, globals) {
|
|
const dom = new JSDOM(
|
|
'<!DOCTYPE html><html><body><video></video></body></html>',
|
|
{ url, runScripts: 'dangerously' });
|
|
const win = dom.window;
|
|
for (const [k, v] of Object.entries(globals)) win[k] = v;
|
|
const result = win.eval(`(${probeFnSource})();`);
|
|
if (!Array.isArray(result)) throw new Error('m3u8DeepProbeFn did not return an array');
|
|
return result;
|
|
}
|
|
|
|
// Reproduces a provider whose pageContext.langUrls holds server-side route
|
|
// TEMPLATES / nav links (e.g. /playlists/, /playlist/%1%/video/%2%/) that the
|
|
// old /playlist|/manifest substring check misclassified as "manifests".
|
|
const TEMPLATE_URLS = [
|
|
'https://example.com/playlists/',
|
|
'https://example.com/playlists/liked/',
|
|
'https://example.com/playlists/popular/',
|
|
'https://example.com/playlists/commented/',
|
|
'https://example.com/playlists/updated/',
|
|
'https://example.com/playlists/largest/',
|
|
'https://example.com/playlist/%1%/video/%2%/',
|
|
'https://example.com/playlists/?category=%DIR%',
|
|
'https://example.com/members/%ID%/playlists/',
|
|
'https://example.com/my/playlists/%ID%/',
|
|
];
|
|
|
|
it('route templates / nav links are NOT reported as manifests', () => {
|
|
const result = runProbeWin('https://example.com/videos/redneck/', {
|
|
pageContext: { langUrls: TEMPLATE_URLS },
|
|
kvsplayer: { config: { langUrls: TEMPLATE_URLS } },
|
|
__goon_plugin_data: { manifests: [], sources: [] },
|
|
});
|
|
const urls = result.map(m => m.url);
|
|
const leaked = TEMPLATE_URLS.filter(u => urls.includes(u));
|
|
if (leaked.length > 0) {
|
|
throw new Error(`template/nav URLs leaked as manifests: ${leaked.join(', ')}`);
|
|
}
|
|
});
|
|
|
|
it('a real .m3u8 with a %2B token is still found (no over-rejection)', () => {
|
|
// %2B is a valid percent-encoded token, not a placeholder — it must survive.
|
|
const real = 'https://cdn.example.com/live/master.m3u8?h=a%2Bb%2Fc&e=1234';
|
|
const result = runProbeWin('https://example.com/videos/redneck/', {
|
|
hls: { url: real },
|
|
pageContext: { langUrls: TEMPLATE_URLS },
|
|
});
|
|
if (!result.some(m => m.url === real)) {
|
|
throw new Error(`real manifest with %2B token was dropped: ${real} — got: ${result.map(m => m.url).join(', ')}`);
|
|
}
|
|
// And the templates still must not leak in the same run.
|
|
const leaked = TEMPLATE_URLS.filter(u => result.some(m => m.url === u));
|
|
if (leaked.length > 0) {
|
|
throw new Error(`templates leaked alongside real manifest: ${leaked.join(', ')}`);
|
|
}
|
|
});
|
|
});
|