CI / test (pull_request) Successful in 27s
Decompose monolithic wirePlaybackTracking() into single-responsibility modules: - PanelState: per-panel playback state (phase, segments, buffer, quality) - EventWiring: attaches hls.js + video element event listeners - BufferMonitor: periodic buffer health check with timer lifecycle - PanelRenderer: per-panel overlay rendering (phase, quality, segments) - AggregateRenderer: global status bar aggregating all panel states - PanelFactory: DOM panel creation + HLS/direct playback routing Each module handles one concern: state tracking, event registration, buffer monitoring, overlay rendering, aggregate rendering, or DOM creation. Also fix: walk() dead code bug in m3u8DeepProbeFn where the string check was unreachable behind the object-type guard.
1169 lines
43 KiB
JavaScript
1169 lines
43 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];
|
|
|
|
// Helper: eval the probe function inside a JSDOM window, then return results
|
|
function runProbeInWin(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;
|
|
|
|
// Mock Hls with cast API
|
|
const video = win.document.getElementById('v');
|
|
win.Hls = class {
|
|
static cast(v) {
|
|
if (v === video) return { url: 'https://cdn.example.com/cast.m3u8' };
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const results = runProbeInWin(win);
|
|
|
|
const found = results.find(r => r.player === 'Hls.cast');
|
|
if (!found) throw new Error('Hls.cast manifest 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 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}`);
|
|
}
|
|
if (!cdn.includes('.17')) {
|
|
throw new Error(`HLS_CDN version should match package.json dependency: ${cdn}`);
|
|
}
|
|
});
|
|
|
|
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('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}`);
|
|
}
|
|
});
|
|
});
|