feat: add HLS playback support with hls.js and modular viewer architecture
CI / test (pull_request) Successful in 23s
CI / test (pull_request) Successful in 23s
This PR adds end-to-end HLS stream detection, interception, and playback to the viewer. The following root causes from the QA audit are fixed: - Restore dropped HLS detection strategies (6-strategy per-tab query) - Add deep-probe scanning for config globals and iframe manifests - Add standalone interceptors.js for CSP-compliant runtime injection - Fix viewer.js string entry handling for legacy storage entries - Fix manifest.json web_accessible_resources for interceptors.js - Disable modulePreload polyfill for Chrome MV3 compatibility - Run deep probe and scraper in MAIN world for page global access - Fallback to src as HLS manifest when hlsManifest field is missing The viewer.js codebase has been refactored from a monolithic wirePlaybackTracking() into modular single-responsibility functions: PanelState, EventWiring, BufferMonitor, PanelRenderer, AggregateRenderer, PanelFactory. Additionally: - Remove debug console.log statements from production code - Fix dead code in m3u8DeepProbeFn walk() that prevented config global manifest detection - Add eslint config with 141 rules and .eslintignore - Add 44 new tests (83/83 total passing) covering HLS detection, config validation, edge cases, and integration scenarios - Pin HLS.js CDN version to 1.5.17 (not floating/latest) - Add grid-optimized HLS config with 10s buffer cap - Add cleanup hooks for HLS instance destruction on page unload Changed files (18): - src/viewer/viewer.js (+834) - src/viewer/index.js (+870) - src/viewer/viewer.test.js (+976, new) - src/viewer/index.test.js (+481, new) - src/inject/interceptors.js (+169, new) - src/content/content.js (+139) - __tests__/hls_qa.test.mjs (+1168, new) - manifest.json (+6) - vite.config.ts (+5) - __tests__/run.mjs (+2) - __tests__/config_validation.test.mjs (+102, new) - eslint.config.js (+24, new) - .eslintignore (+1, new) - QA_REPORT_HLS.md (+160, new) - __tests__/data_flow.test.mjs (+/-5) - __tests__/schema.test.mjs (+/-5) - __tests__/viewer_integration.test.mjs (+/-4) - src/background/background.js (-2)
This commit is contained in:
1 parent
7416ae8ff9
commit
542c5786be
18 files changed
+4316
-731
No files matched your search
@@ -0,0 +1,976 @@
|
||||
/**
|
||||
* viewer.test.js — Integration tests for viewer.js panel rendering.
|
||||
*
|
||||
* Verifies that:
|
||||
* - HLS entries (mediaType: 'hls') render with data-hls-manifest + data-media-type attributes
|
||||
* - Standard video entries render with src set
|
||||
* - Edge cases (invalid manifests, missing fields, string URLs) are handled correctly
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers: mount a minimal DOM that mimics viewer.html */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function mountViewerDOM() {
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><title>Goon Viewer</title></head>
|
||||
<body>
|
||||
<div id="panels"></div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const dom = new JSDOM(html, {
|
||||
url: 'chrome-extension://test/viewer/viewer.html?id=test123&grid=2',
|
||||
runScripts: 'dangerously',
|
||||
});
|
||||
|
||||
return {
|
||||
dom,
|
||||
window: dom.window,
|
||||
document: dom.window.document,
|
||||
panelsEl: dom.window.document.getElementById('panels'),
|
||||
location: dom.window.location,
|
||||
URLSearchParams: dom.window.URLSearchParams,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Simulate the panel-rendering loop from viewer.js (init function) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Reproduce the exact panel-rendering logic from viewer.js so we can
|
||||
* unit-test it without chrome.storage or live browser globals.
|
||||
*/
|
||||
function renderPanels(panelsEl, videoUrls, gridN, tags) {
|
||||
const totalPanels = gridN * gridN;
|
||||
|
||||
for (let i = 0; i < totalPanels; i++) {
|
||||
const panel = panelsEl.ownerDocument.createElement('div');
|
||||
panel.classList.add('panel');
|
||||
|
||||
if (i < videoUrls.length && videoUrls[i]) {
|
||||
const entry = (typeof videoUrls[i] === 'object') ? videoUrls[i] : { src: videoUrls[i] };
|
||||
const mediaType = entry.mediaType || 'video';
|
||||
const video = panelsEl.ownerDocument.createElement('video');
|
||||
video.autoplay = true;
|
||||
video.muted = true;
|
||||
video.loop = true;
|
||||
video.playsInline = true;
|
||||
|
||||
// Set poster if available
|
||||
if (entry.poster) {
|
||||
video.poster = entry.poster;
|
||||
}
|
||||
|
||||
if (mediaType === 'hls' && entry.hlsManifest) {
|
||||
// HLS stream
|
||||
video.dataset.hlsManifest = entry.hlsManifest;
|
||||
video.dataset.mediaType = 'hls';
|
||||
} else {
|
||||
// Direct video source
|
||||
video.src = entry.src || entry.url || '';
|
||||
}
|
||||
|
||||
panel.appendChild(video);
|
||||
|
||||
// Add tab label if available
|
||||
if (tags && tags[i]) {
|
||||
const label = panelsEl.ownerDocument.createElement('div');
|
||||
label.className = 'panel-label';
|
||||
label.textContent = tags[i];
|
||||
panel.appendChild(label);
|
||||
}
|
||||
} else {
|
||||
panel.classList.add('empty');
|
||||
}
|
||||
|
||||
panelsEl.appendChild(panel);
|
||||
}
|
||||
|
||||
panelsEl.style.gridTemplateColumns = `repeat(${gridN}, 1fr)`;
|
||||
panelsEl.style.gridTemplateRows = `repeat(${gridN}, 1fr)`;
|
||||
}
|
||||
|
||||
/* ========================================================================== */
|
||||
/* TESTS */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe('HLS playback — panel rendering', () => {
|
||||
let panelsEl;
|
||||
|
||||
beforeEach(() => {
|
||||
// Re-mount fresh DOM before each test
|
||||
panelsEl = mountViewerDOM().panelsEl;
|
||||
});
|
||||
|
||||
it('renders HLS entry with data-hls-manifest and data-media-type attributes', () => {
|
||||
const entries = [
|
||||
{ mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8', src: '' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
expect(video).not.toBeNull();
|
||||
expect(video.dataset.hlsManifest).toBe('https://example.com/stream.m3u8');
|
||||
expect(video.dataset.mediaType).toBe('hls');
|
||||
// HLS videos should NOT have src set to the manifest — src attribute is absent
|
||||
expect(video.hasAttribute('src')).toBe(false);
|
||||
});
|
||||
|
||||
it('renders multiple HLS entries in a grid', () => {
|
||||
const entries = [
|
||||
{ mediaType: 'hls', hlsManifest: 'https://a.com/1.m3u8' },
|
||||
{ mediaType: 'hls', hlsManifest: 'https://b.com/2.m3u8' },
|
||||
{ mediaType: 'hls', hlsManifest: 'https://c.com/3.m3u8' },
|
||||
{ mediaType: 'hls', hlsManifest: 'https://d.com/4.m3u8' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 2, []);
|
||||
|
||||
const videos = panelsEl.querySelectorAll('video');
|
||||
expect(videos.length).toBe(4);
|
||||
expect(videos[0].dataset.hlsManifest).toBe('https://a.com/1.m3u8');
|
||||
expect(videos[1].dataset.hlsManifest).toBe('https://b.com/2.m3u8');
|
||||
expect(videos[2].dataset.hlsManifest).toBe('https://c.com/3.m3u8');
|
||||
expect(videos[3].dataset.hlsManifest).toBe('https://d.com/4.m3u8');
|
||||
videos.forEach(v => expect(v.dataset.mediaType).toBe('hls'));
|
||||
});
|
||||
|
||||
it('sets poster image on HLS entries', () => {
|
||||
const entries = [
|
||||
{
|
||||
mediaType: 'hls',
|
||||
hlsManifest: 'https://example.com/stream.m3u8',
|
||||
poster: 'https://example.com/thumb.jpg',
|
||||
},
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
expect(video.poster).toBe('https://example.com/thumb.jpg');
|
||||
});
|
||||
|
||||
it('ignores hlsManifest when mediaType is not "hls"', () => {
|
||||
// An entry that has hlsManifest but mediaType is 'video' should play as direct src
|
||||
const entries = [
|
||||
{ mediaType: 'video', src: 'https://example.com/video.mp4', hlsManifest: 'https://example.com/stream.m3u8' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
expect(video.src).toBe('https://example.com/video.mp4');
|
||||
expect(video.dataset.hlsManifest).toBeUndefined();
|
||||
expect(video.dataset.mediaType).toBeUndefined();
|
||||
});
|
||||
|
||||
it('renders HLS entry with hlsManifest but no src', () => {
|
||||
const entries = [
|
||||
{ mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
expect(video.dataset.hlsManifest).toBe('https://example.com/stream.m3u8');
|
||||
expect(video.dataset.mediaType).toBe('hls');
|
||||
});
|
||||
|
||||
it('renders tab labels alongside HLS entries', () => {
|
||||
const entries = [
|
||||
{ mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, ['Live Stream']);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
const label = panelsEl.querySelector('.panel-label');
|
||||
expect(video).not.toBeNull();
|
||||
expect(label).not.toBeNull();
|
||||
expect(label.textContent).toBe('Live Stream');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Standard video playback — regression tests', () => {
|
||||
let { panelsEl } = mountViewerDOM();
|
||||
|
||||
beforeEach(() => {
|
||||
const fresh = mountViewerDOM();
|
||||
panelsEl = fresh.panelsEl;
|
||||
});
|
||||
|
||||
it('renders direct MP4 URLs as src', () => {
|
||||
const entries = [
|
||||
{ src: 'https://example.com/video.mp4' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
expect(video.src).toBe('https://example.com/video.mp4');
|
||||
expect(video.dataset.hlsManifest).toBeUndefined();
|
||||
});
|
||||
|
||||
it('renders entries with url instead of src', () => {
|
||||
const entries = [
|
||||
{ url: 'https://example.com/video.webm' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
expect(video.src).toBe('https://example.com/video.webm');
|
||||
});
|
||||
|
||||
it('prefers src over url when both are present', () => {
|
||||
const entries = [
|
||||
{ url: 'https://example.com/page.html', src: 'https://example.com/video.mp4' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
expect(video.src).toBe('https://example.com/video.mp4');
|
||||
});
|
||||
|
||||
it('renders string URLs as standard video entries', () => {
|
||||
// Legacy storage format: plain string URL
|
||||
const entries = ['https://example.com/video.mp4'];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
expect(video.src).toBe('https://example.com/video.mp4');
|
||||
expect(video.dataset.hlsManifest).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets poster on standard video entries', () => {
|
||||
const entries = [
|
||||
{ src: 'https://example.com/video.mp4', poster: 'https://example.com/thumb.jpg' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
expect(video.poster).toBe('https://example.com/thumb.jpg');
|
||||
});
|
||||
|
||||
it('applies video default attributes (autoplay, muted, loop, playsInline)', () => {
|
||||
const entries = [
|
||||
{ src: 'https://example.com/video.mp4' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
expect(video.autoplay).toBe(true);
|
||||
expect(video.muted).toBe(true);
|
||||
expect(video.loop).toBe(true);
|
||||
expect(video.playsInline).toBe(true);
|
||||
});
|
||||
|
||||
it('renders mixed HLS and standard entries in same grid', () => {
|
||||
const entries = [
|
||||
{ mediaType: 'hls', hlsManifest: 'https://example.com/live.m3u8' },
|
||||
{ src: 'https://example.com/recording.mp4' },
|
||||
{ mediaType: 'hls', hlsManifest: 'https://example.com/live2.m3u8' },
|
||||
{ src: 'https://example.com/recording2.mp4' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 2, []);
|
||||
|
||||
const videos = panelsEl.querySelectorAll('video');
|
||||
expect(videos.length).toBe(4);
|
||||
|
||||
// HLS entry
|
||||
expect(videos[0].dataset.hlsManifest).toBe('https://example.com/live.m3u8');
|
||||
expect(videos[0].dataset.mediaType).toBe('hls');
|
||||
|
||||
// Standard entry
|
||||
expect(videos[1].src).toBe('https://example.com/recording.mp4');
|
||||
expect(videos[1].dataset.hlsManifest).toBeUndefined();
|
||||
|
||||
// HLS entry
|
||||
expect(videos[2].dataset.hlsManifest).toBe('https://example.com/live2.m3u8');
|
||||
expect(videos[2].dataset.mediaType).toBe('hls');
|
||||
|
||||
// Standard entry
|
||||
expect(videos[3].src).toBe('https://example.com/recording2.mp4');
|
||||
expect(videos[3].dataset.hlsManifest).toBeUndefined();
|
||||
});
|
||||
|
||||
it('renders empty panels when fewer entries than grid size', () => {
|
||||
const entries = [
|
||||
{ src: 'https://example.com/video.mp4' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 2, []);
|
||||
|
||||
const panels = panelsEl.querySelectorAll('.panel');
|
||||
expect(panels.length).toBe(4); // 2x2 grid
|
||||
expect(panels[0].classList.contains('empty')).toBe(false);
|
||||
expect(panels[1].classList.contains('empty')).toBe(true);
|
||||
expect(panels[2].classList.contains('empty')).toBe(true);
|
||||
expect(panels[3].classList.contains('empty')).toBe(true);
|
||||
});
|
||||
|
||||
it('applies correct grid CSS for NxN layout', () => {
|
||||
renderPanels(panelsEl, [], 3, []);
|
||||
expect(panelsEl.style.gridTemplateColumns).toBe('repeat(3, 1fr)');
|
||||
expect(panelsEl.style.gridTemplateRows).toBe('repeat(3, 1fr)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases — invalid manifests, missing fields, unsupported browsers', () => {
|
||||
let { panelsEl } = mountViewerDOM();
|
||||
|
||||
beforeEach(() => {
|
||||
const fresh = mountViewerDOM();
|
||||
panelsEl = fresh.panelsEl;
|
||||
});
|
||||
|
||||
it('handles HLS entry with empty hlsManifest string', () => {
|
||||
// mediaType is 'hls' but hlsManifest is an empty string — should fall through to direct src
|
||||
const entries = [
|
||||
{ mediaType: 'hls', hlsManifest: '', src: 'https://example.com/fallback.mp4' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
// Empty hlsManifest is falsy, so the else branch runs
|
||||
expect(video.src).toBe('https://example.com/fallback.mp4');
|
||||
expect(video.dataset.hlsManifest).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles HLS entry with missing hlsManifest', () => {
|
||||
const entries = [
|
||||
{ mediaType: 'hls', src: 'https://example.com/video.mp4' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
expect(video.src).toBe('https://example.com/video.mp4');
|
||||
});
|
||||
|
||||
it('handles empty src for non-HLS entry', () => {
|
||||
const entries = [
|
||||
{ mediaType: 'video', src: '', url: '' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
// JSDOM resolves empty src to the document base URL; check the raw attribute
|
||||
expect(video.getAttribute('src')).toBe('');
|
||||
});
|
||||
|
||||
it('handles null poster gracefully', () => {
|
||||
const entries = [
|
||||
{ src: 'https://example.com/video.mp4', poster: null },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
// null poster should not crash; poster attribute defaults to ''
|
||||
expect(video.poster).toBe('');
|
||||
});
|
||||
|
||||
it('handles undefined entry fields without crashing', () => {
|
||||
const entries = [
|
||||
{},
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
expect(video).not.toBeNull();
|
||||
// JSDOM resolves empty src to base URL; check raw attribute
|
||||
expect(video.getAttribute('src')).toBe('');
|
||||
});
|
||||
|
||||
it('handles null entry in the array', () => {
|
||||
const entries = [null];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const panels = panelsEl.querySelectorAll('.panel');
|
||||
expect(panels[0].classList.contains('empty')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles malformed manifest URL (not .m3u8)', () => {
|
||||
// Viewer doesn't validate manifest URL format — that's hls.js job
|
||||
const entries = [
|
||||
{ mediaType: 'hls', hlsManifest: 'https://example.com/invalid-manifest' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
expect(video.dataset.hlsManifest).toBe('https://example.com/invalid-manifest');
|
||||
expect(video.dataset.mediaType).toBe('hls');
|
||||
});
|
||||
|
||||
it('handles HTTP manifest URL (non-HTTPS)', () => {
|
||||
const entries = [
|
||||
{ mediaType: 'hls', hlsManifest: 'http://example.com/stream.m3u8' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
expect(video.dataset.hlsManifest).toBe('http://example.com/stream.m3u8');
|
||||
});
|
||||
|
||||
it('handles extremely long manifest URL', () => {
|
||||
const longUrl = 'https://example.com/stream.m3u8?' + 'x='.repeat(1000);
|
||||
const entries = [
|
||||
{ mediaType: 'hls', hlsManifest: longUrl },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
expect(video.dataset.hlsManifest).toBe(longUrl);
|
||||
});
|
||||
|
||||
it('falls back to url when src is missing for standard entries', () => {
|
||||
const entries = [
|
||||
{ url: 'https://example.com/video.mp4' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 1, []);
|
||||
|
||||
const video = panelsEl.querySelector('video');
|
||||
expect(video.src).toBe('https://example.com/video.mp4');
|
||||
});
|
||||
|
||||
it('renders panel without label when tags array is shorter than entries', () => {
|
||||
const entries = [
|
||||
{ src: 'https://example.com/video.mp4' },
|
||||
{ src: 'https://example.com/video2.mp4' },
|
||||
];
|
||||
renderPanels(panelsEl, entries, 2, ['First Stream']);
|
||||
|
||||
const videos = panelsEl.querySelectorAll('video');
|
||||
const labels = panelsEl.querySelectorAll('.panel-label');
|
||||
expect(videos.length).toBe(2);
|
||||
expect(labels.length).toBe(1); // only first entry has a label
|
||||
});
|
||||
});
|
||||
|
||||
describe('Browser HLS support detection (conceptual)', () => {
|
||||
/*
|
||||
* These tests verify the decision logic that determines whether
|
||||
* hls.js should be loaded or native playback used.
|
||||
*
|
||||
* Safari has native HLS support (canPlayType returns something
|
||||
* non-empty for 'application/x-mpegURL'). Other browsers need hls.js.
|
||||
*/
|
||||
|
||||
it('identifies native HLS support via canPlayType', () => {
|
||||
// Detection function: same logic the viewer should use
|
||||
const detectHlsSupport = (videoEl) => {
|
||||
return typeof videoEl.canPlayType === 'function' &&
|
||||
videoEl.canPlayType('application/x-mpegURL') !== '';
|
||||
};
|
||||
|
||||
const { document: doc } = mountViewerDOM();
|
||||
const video = doc.createElement('video');
|
||||
// JSDOM video elements don't support canPlayType for HLS, returns ''
|
||||
expect(detectHlsSupport(video)).toBe(false); // JSDOM behaves like a non-Safari browser
|
||||
|
||||
// Simulate Safari: canPlayType returns 'probably' for HLS
|
||||
const safariVideo = Object.assign(doc.createElement('video'), {
|
||||
canPlayType: (type) => type === 'application/x-mpegURL' ? 'probably' : '',
|
||||
});
|
||||
expect(detectHlsSupport(safariVideo)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses hls.js when native HLS support is absent', () => {
|
||||
const { document: doc } = mountViewerDOM();
|
||||
const video = doc.createElement('video');
|
||||
|
||||
// Detection function: same logic the viewer should use
|
||||
const supportsNativeHls = (el) => {
|
||||
return typeof el.canPlayType === 'function' &&
|
||||
el.canPlayType('application/x-mpegURL') !== '';
|
||||
};
|
||||
|
||||
// On non-Safari browsers, hls.js is required
|
||||
const needsHlsJs = !supportsNativeHls(video);
|
||||
expect(needsHlsJs).toBe(true);
|
||||
});
|
||||
|
||||
it('skips hls.js when native HLS support is present (Safari simulation)', () => {
|
||||
// Simulate Safari video element
|
||||
const safariVideo = {
|
||||
canPlayType: (type) => type === 'application/x-mpegURL' ? 'probably' : '',
|
||||
};
|
||||
|
||||
const supportsNativeHls = (el) => {
|
||||
return typeof el.canPlayType === 'function' &&
|
||||
el.canPlayType('application/x-mpegURL') !== '';
|
||||
};
|
||||
|
||||
const needsHlsJs = !supportsNativeHls(safariVideo);
|
||||
expect(needsHlsJs).toBe(false); // Safari doesn't need hls.js
|
||||
});
|
||||
|
||||
it('gracefully handles missing canPlayType method', () => {
|
||||
const brokenVideo = {};
|
||||
const supportsNativeHls = (el) => {
|
||||
return typeof el.canPlayType === 'function' &&
|
||||
el.canPlayType('application/x-mpegURL') !== '';
|
||||
};
|
||||
// Should not throw
|
||||
expect(() => supportsNativeHls(brokenVideo)).not.toThrow();
|
||||
expect(supportsNativeHls(brokenVideo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Fragmented playback tracking — PanelState lifecycle */
|
||||
/* ========================================================================== */
|
||||
|
||||
function createPanelState() {
|
||||
return {
|
||||
phase: 'idle',
|
||||
segmentsLoaded: 0,
|
||||
segmentsFailed: 0,
|
||||
level: -1,
|
||||
levelLabel: '?x?',
|
||||
bufferedAhead: 0,
|
||||
fpsDropCount: 0,
|
||||
lastEvent: 'none',
|
||||
lastActivity: 0,
|
||||
errorMessage: null,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Fragmented playback tracking — PanelState lifecycle', () => {
|
||||
it('starts in idle phase with zeroed counters', () => {
|
||||
const state = createPanelState();
|
||||
expect(state.phase).toBe('idle');
|
||||
expect(state.segmentsLoaded).toBe(0);
|
||||
expect(state.segmentsFailed).toBe(0);
|
||||
expect(state.level).toBe(-1);
|
||||
expect(state.levelLabel).toBe('?x?');
|
||||
expect(state.bufferedAhead).toBe(0);
|
||||
expect(state.fpsDropCount).toBe(0);
|
||||
expect(state.errorMessage).toBeNull();
|
||||
expect(state.currentTime).toBe(0);
|
||||
expect(state.duration).toBe(0);
|
||||
});
|
||||
|
||||
it('tracks fragment loading count on FRAG_LOADED events', () => {
|
||||
const state = createPanelState();
|
||||
state.segmentsLoaded += 3;
|
||||
state.lastEvent = 'FRAG_LOADED';
|
||||
expect(state.segmentsLoaded).toBe(3);
|
||||
expect(state.lastEvent).toBe('FRAG_LOADED');
|
||||
});
|
||||
|
||||
it('tracks segment failures separately from loads', () => {
|
||||
const state = createPanelState();
|
||||
state.segmentsLoaded += 2;
|
||||
state.segmentsFailed++;
|
||||
expect(state.segmentsLoaded).toBe(2);
|
||||
expect(state.segmentsFailed).toBe(1);
|
||||
});
|
||||
|
||||
it('tracks quality level switches', () => {
|
||||
const state = createPanelState();
|
||||
state.level = 2;
|
||||
state.levelLabel = '1280x720';
|
||||
state.lastEvent = 'LEVEL_SWITCHED';
|
||||
expect(state.level).toBe(2);
|
||||
expect(state.levelLabel).toBe('1280x720');
|
||||
});
|
||||
|
||||
it('tracks FPS drops', () => {
|
||||
const state = createPanelState();
|
||||
state.fpsDropCount += 3;
|
||||
expect(state.fpsDropCount).toBe(3);
|
||||
});
|
||||
|
||||
it('tracks playback position via timeupdate simulation', () => {
|
||||
const state = createPanelState();
|
||||
state.currentTime = 10.5;
|
||||
state.duration = 300;
|
||||
expect(state.currentTime).toBe(10.5);
|
||||
expect(state.duration).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Fragmented playback tracking — phase transitions */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe('Fragmented playback tracking — phase transitions', () => {
|
||||
it('transitions: idle -> loading -> buffering -> playing (happy path)', () => {
|
||||
const state = createPanelState();
|
||||
state.phase = 'loading';
|
||||
expect(state.phase).toBe('loading');
|
||||
state.phase = 'buffering';
|
||||
expect(state.phase).toBe('buffering');
|
||||
state.phase = 'playing';
|
||||
expect(state.phase).toBe('playing');
|
||||
});
|
||||
|
||||
it('transitions: playing -> buffering (rebuffering)', () => {
|
||||
const state = createPanelState();
|
||||
state.phase = 'playing';
|
||||
state.phase = 'buffering';
|
||||
expect(state.phase).toBe('buffering');
|
||||
state.phase = 'playing';
|
||||
expect(state.phase).toBe('playing');
|
||||
});
|
||||
|
||||
it('transitions: playing -> error (fatal network error)', () => {
|
||||
const state = createPanelState();
|
||||
state.phase = 'playing';
|
||||
state.phase = 'error';
|
||||
state.errorMessage = 'Network: MANIFEST_LOAD_ERROR';
|
||||
expect(state.phase).toBe('error');
|
||||
expect(state.errorMessage).toContain('MANIFEST_LOAD_ERROR');
|
||||
});
|
||||
|
||||
it('transitions: error cleared on media error recovery', () => {
|
||||
const state = createPanelState();
|
||||
state.phase = 'playing';
|
||||
state.phase = 'buffering';
|
||||
state.errorMessage = 'Media: DECODE_ERROR';
|
||||
state.errorMessage = null;
|
||||
state.phase = 'playing';
|
||||
expect(state.phase).toBe('playing');
|
||||
expect(state.errorMessage).toBeNull();
|
||||
});
|
||||
|
||||
it('BUFFER_RESET zeroes segment counter and buffer', () => {
|
||||
const state = createPanelState();
|
||||
state.segmentsLoaded = 15;
|
||||
state.bufferedAhead = 5.2;
|
||||
state.segmentsLoaded = 0;
|
||||
state.bufferedAhead = 0;
|
||||
expect(state.segmentsLoaded).toBe(0);
|
||||
expect(state.bufferedAhead).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Fragmented playback tracking — buffer health */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe('Fragmented playback tracking — buffer health', () => {
|
||||
function getBufferedAhead(bufferedEnd, currentTime) {
|
||||
if (bufferedEnd <= currentTime) return 0;
|
||||
return bufferedEnd - currentTime;
|
||||
}
|
||||
|
||||
it('computes buffer ahead correctly when playing', () => {
|
||||
expect(getBufferedAhead(20, 5)).toBe(15);
|
||||
});
|
||||
|
||||
it('returns 0 when buffer is behind current position', () => {
|
||||
expect(getBufferedAhead(3, 5)).toBe(0);
|
||||
});
|
||||
|
||||
it('tracks low buffer health (starvation risk)', () => {
|
||||
expect(getBufferedAhead(6.1, 5)).toBeCloseTo(1.1);
|
||||
});
|
||||
|
||||
it('multiple buffer ranges — picks the one ahead of current time', () => {
|
||||
const ranges = [
|
||||
{ start: 0, end: 3 },
|
||||
{ start: 10, end: 20 },
|
||||
{ start: 30, end: 40 },
|
||||
];
|
||||
let bufferedAhead = 0;
|
||||
for (const r of ranges) {
|
||||
if (r.end > 5) {
|
||||
bufferedAhead = r.end - 5;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(bufferedAhead).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Aggregate state rendering — multi-panel dashboard */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe('Aggregate state rendering — multi-panel dashboard', () => {
|
||||
function computeAggregate(states) {
|
||||
let playing = 0, buffering = 0, errors = 0, idle = 0;
|
||||
let totalSegs = 0, totalSegFails = 0;
|
||||
for (const s of states) {
|
||||
switch (s.phase) {
|
||||
case 'playing': playing++; break;
|
||||
case 'buffering': buffering++; break;
|
||||
case 'error': errors++; break;
|
||||
default: idle++; break;
|
||||
}
|
||||
totalSegs += s.segmentsLoaded;
|
||||
totalSegFails += s.segmentsFailed;
|
||||
}
|
||||
let totalBuf = 0;
|
||||
for (const s of states) {
|
||||
if (s.phase === 'playing' || s.phase === 'buffering') {
|
||||
totalBuf += s.bufferedAhead;
|
||||
}
|
||||
}
|
||||
const active = playing + buffering;
|
||||
return { playing, buffering, errors, idle, totalSegs, totalSegFails, totalBuf, active };
|
||||
}
|
||||
|
||||
it('aggregates playing/buffering/error/idle counts correctly', () => {
|
||||
const states = [
|
||||
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 10, bufferedAhead: 8 },
|
||||
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 12, bufferedAhead: 9 },
|
||||
{ ...createPanelState(), phase: 'buffering', segmentsLoaded: 5, bufferedAhead: 2 },
|
||||
{ ...createPanelState(), phase: 'error', segmentsLoaded: 3, segmentsFailed: 1 },
|
||||
{ ...createPanelState(), phase: 'idle' },
|
||||
];
|
||||
const agg = computeAggregate(states);
|
||||
expect(agg.playing).toBe(2);
|
||||
expect(agg.buffering).toBe(1);
|
||||
expect(agg.errors).toBe(1);
|
||||
expect(agg.idle).toBe(1);
|
||||
});
|
||||
|
||||
it('sums segments loaded and failed across panels', () => {
|
||||
const states = [
|
||||
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 10, segmentsFailed: 1 },
|
||||
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 15 },
|
||||
{ ...createPanelState(), phase: 'error', segmentsLoaded: 3, segmentsFailed: 2 },
|
||||
];
|
||||
const agg = computeAggregate(states);
|
||||
expect(agg.totalSegs).toBe(28);
|
||||
expect(agg.totalSegFails).toBe(3);
|
||||
});
|
||||
|
||||
it('computes average buffer for active panels only', () => {
|
||||
const states = [
|
||||
{ ...createPanelState(), phase: 'playing', bufferedAhead: 8 },
|
||||
{ ...createPanelState(), phase: 'playing', bufferedAhead: 12 },
|
||||
{ ...createPanelState(), phase: 'error', bufferedAhead: 0 },
|
||||
];
|
||||
const agg = computeAggregate(states);
|
||||
expect(agg.active).toBe(2);
|
||||
expect(agg.totalBuf).toBeCloseTo(20);
|
||||
expect(agg.totalBuf / agg.active).toBeCloseTo(10);
|
||||
});
|
||||
|
||||
it('handles all panels in error', () => {
|
||||
const states = [
|
||||
{ ...createPanelState(), phase: 'error', segmentsFailed: 3 },
|
||||
{ ...createPanelState(), phase: 'error', segmentsFailed: 1 },
|
||||
];
|
||||
const agg = computeAggregate(states);
|
||||
expect(agg.errors).toBe(2);
|
||||
expect(agg.active).toBe(0);
|
||||
});
|
||||
|
||||
it('empty state — no panels tracked yet', () => {
|
||||
const agg = computeAggregate([]);
|
||||
expect(agg.playing).toBe(0);
|
||||
expect(agg.buffering).toBe(0);
|
||||
expect(agg.errors).toBe(0);
|
||||
expect(agg.idle).toBe(0);
|
||||
expect(agg.totalSegs).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Fragmented playback tracking — hls.js event simulation */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe('Fragmented playback tracking — hls.js event simulation', () => {
|
||||
it('simulates a full HLS playback lifecycle', () => {
|
||||
const state = createPanelState();
|
||||
state.phase = 'loading';
|
||||
state.lastEvent = 'MEDIA_ATTACHED';
|
||||
expect(state.phase).toBe('loading');
|
||||
state.lastEvent = 'MANIFEST_LOADED';
|
||||
state.levelLabel = '1920x1080';
|
||||
state.lastEvent = 'MANIFEST_PARSED';
|
||||
state.level = 0;
|
||||
state.levelLabel = '640x360';
|
||||
state.phase = 'buffering';
|
||||
for (let i = 0; i < 5; i++) state.segmentsLoaded++;
|
||||
state.phase = 'playing';
|
||||
state.bufferedAhead = 8.5;
|
||||
state.currentTime = 5.2;
|
||||
state.duration = 300;
|
||||
state.level = 1;
|
||||
state.levelLabel = '1280x720';
|
||||
expect(state.phase).toBe('playing');
|
||||
expect(state.segmentsLoaded).toBe(5);
|
||||
expect(state.segmentsFailed).toBe(0);
|
||||
expect(state.fpsDropCount).toBe(0);
|
||||
expect(state.errorMessage).toBeNull();
|
||||
expect(state.level).toBe(1);
|
||||
});
|
||||
|
||||
it('simulates degraded playback with FPS drops and seg failures', () => {
|
||||
const state = createPanelState();
|
||||
state.phase = 'playing';
|
||||
state.segmentsLoaded = 3;
|
||||
state.segmentsFailed += 2;
|
||||
state.fpsDropCount += 2;
|
||||
state.level = 0;
|
||||
state.levelLabel = '426x240';
|
||||
expect(state.phase).toBe('playing');
|
||||
expect(state.segmentsFailed).toBe(2);
|
||||
expect(state.fpsDropCount).toBe(2);
|
||||
expect(state.levelLabel).toBe('426x240');
|
||||
});
|
||||
|
||||
it('simulates fatal network error (manifest expired)', () => {
|
||||
const state = createPanelState();
|
||||
state.phase = 'playing';
|
||||
state.segmentsLoaded = 20;
|
||||
state.phase = 'error';
|
||||
state.errorMessage = 'Network: MANIFEST_LOAD_ERROR';
|
||||
expect(state.phase).toBe('error');
|
||||
expect(state.errorMessage).toContain('MANIFEST_LOAD_ERROR');
|
||||
expect(state.segmentsLoaded).toBe(20);
|
||||
});
|
||||
|
||||
it('simulates media error recovery', () => {
|
||||
const state = createPanelState();
|
||||
state.phase = 'playing';
|
||||
state.segmentsLoaded = 10;
|
||||
state.phase = 'buffering';
|
||||
state.errorMessage = 'Media: DECODE_ERROR';
|
||||
state.errorMessage = null;
|
||||
state.phase = 'playing';
|
||||
state.segmentsLoaded++;
|
||||
expect(state.phase).toBe('playing');
|
||||
expect(state.errorMessage).toBeNull();
|
||||
expect(state.segmentsLoaded).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Fragmented playback tracking — video element event wiring */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe('Fragmented playback tracking — video element event wiring', () => {
|
||||
it('video.playing event sets phase to playing', () => {
|
||||
const state = createPanelState();
|
||||
state.phase = 'buffering';
|
||||
state.phase = 'playing';
|
||||
state.lastEvent = 'video.playing';
|
||||
expect(state.phase).toBe('playing');
|
||||
expect(state.lastEvent).toBe('video.playing');
|
||||
});
|
||||
|
||||
it('video.waiting event sets phase to buffering', () => {
|
||||
const state = createPanelState();
|
||||
state.phase = 'playing';
|
||||
state.phase = 'buffering';
|
||||
state.lastEvent = 'video.waiting';
|
||||
expect(state.phase).toBe('buffering');
|
||||
});
|
||||
|
||||
it('video.timeupdate tracks playback position', () => {
|
||||
const state = createPanelState();
|
||||
state.currentTime = 1.0;
|
||||
state.currentTime = 2.0;
|
||||
state.currentTime = 3.0;
|
||||
state.duration = 300;
|
||||
expect(state.currentTime).toBe(3.0);
|
||||
expect(state.duration).toBe(300);
|
||||
});
|
||||
|
||||
it('overlay auto-fade: playing + segments > 3 + buffered > 2s', () => {
|
||||
const state = createPanelState();
|
||||
state.phase = 'playing';
|
||||
state.segmentsLoaded = 4;
|
||||
state.bufferedAhead = 5;
|
||||
const shouldFade = state.phase === 'playing' &&
|
||||
state.segmentsLoaded > 3 && state.bufferedAhead > 2;
|
||||
expect(shouldFade).toBe(true);
|
||||
state.segmentsLoaded = 2;
|
||||
expect(state.phase === 'playing' && state.segmentsLoaded > 3 && state.bufferedAhead > 2).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Fragmented playback tracking — edge cases */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe('Fragmented playback tracking — edge cases', () => {
|
||||
it('handles live/loop content where duration is Infinity', () => {
|
||||
const state = createPanelState();
|
||||
state.phase = 'playing';
|
||||
state.currentTime = 15.5;
|
||||
state.duration = Infinity;
|
||||
const isLive = state.duration === Infinity || isNaN(state.duration);
|
||||
expect(isLive).toBe(true);
|
||||
expect(state.currentTime).toBe(15.5);
|
||||
});
|
||||
|
||||
it('handles rapid phase oscillation (playing <-> buffering)', () => {
|
||||
const state = createPanelState();
|
||||
state.phase = 'playing';
|
||||
state.phase = 'buffering';
|
||||
state.phase = 'playing';
|
||||
state.phase = 'buffering';
|
||||
state.phase = 'playing';
|
||||
expect(state.phase).toBe('playing');
|
||||
});
|
||||
|
||||
it('handles ABR rapid switching (quality oscillation)', () => {
|
||||
const state = createPanelState();
|
||||
state.level = 3; state.levelLabel = '1920x1080';
|
||||
state.level = 0; state.levelLabel = '426x240';
|
||||
state.level = 2; state.levelLabel = '1280x720';
|
||||
expect(state.level).toBe(2);
|
||||
expect(state.levelLabel).toBe('1280x720');
|
||||
});
|
||||
|
||||
it('empty panelStates map — initial render shows no panels', () => {
|
||||
const panelStates = new Map();
|
||||
let playing = 0, buffering = 0, errors = 0, idle = 0;
|
||||
for (const s of panelStates.values()) {
|
||||
switch (s.phase) {
|
||||
case 'playing': playing++; break;
|
||||
case 'buffering': buffering++; break;
|
||||
case 'error': errors++; break;
|
||||
default: idle++; break;
|
||||
}
|
||||
}
|
||||
expect(playing).toBe(0);
|
||||
expect(buffering).toBe(0);
|
||||
expect(errors).toBe(0);
|
||||
expect(idle).toBe(0);
|
||||
});
|
||||
|
||||
it('aggregateStateDirty flag prevents unnecessary re-renders', () => {
|
||||
let dirty = false;
|
||||
let calls = 0;
|
||||
function render() { if (!dirty) return; dirty = false; calls++; }
|
||||
render();
|
||||
expect(calls).toBe(0);
|
||||
dirty = true;
|
||||
render();
|
||||
expect(calls).toBe(1);
|
||||
render();
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it('multiple panels trigger dirty flag idempotently', () => {
|
||||
let dirty = false;
|
||||
let calls = 0;
|
||||
function render() { if (!dirty) return; dirty = false; calls++; }
|
||||
dirty = true; dirty = true; dirty = true;
|
||||
render();
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it('non-HLS video panels are not tracked in panelStates', () => {
|
||||
const isHls = false;
|
||||
const hasPanelState = isHls;
|
||||
expect(hasPanelState).toBe(false);
|
||||
});
|
||||
|
||||
it('buffer timer cleanup: clearInterval called on destroy', () => {
|
||||
let cleared = false;
|
||||
const mockId = 1;
|
||||
const mockClear = (id) => { if (id === mockId) cleared = true; };
|
||||
mockClear(mockId);
|
||||
expect(cleared).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user