CI / test (pull_request) Successful in 16s
- Defensive null check on video.play() return value (jsdom returns undefined) - Fix test mock: win.Hls (not win.HLS) to match viewer.js window.Hls reference - All 39/39 tests now pass
512 lines
17 KiB
JavaScript
512 lines
17 KiB
JavaScript
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'
|
|
);
|
|
|
|
/**
|
|
* Create a JSDOM environment with mocked Chrome extension APIs.
|
|
*
|
|
* @param {object} options
|
|
* @param {object} options.storageData - Initial chrome.storage.local contents
|
|
* @param {string} options.url - Page URL (must include query params viewer.js reads at load)
|
|
* @param {string} options.options.hlsMode - 'native' (Safari-like: window.HLS undefined),
|
|
* 'mocked' (Hls.js loaded with mocked API), or 'none' (no mocking — CDN fetches hang)
|
|
*/
|
|
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;
|
|
|
|
// Shared mutable storage object
|
|
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();
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
|
|
// Mock HLS to avoid hanging on CDN fetch in jsdom.
|
|
// jsdom does not fire onerror for dynamically loaded <script> tags,
|
|
// so loadHlsJs() would block forever trying CDN fallbacks.
|
|
// 'mocked' mode provides a fake Hls.js that records calls for verification.
|
|
// 'native' mode sets HLS = undefined so viewer.js takes the Safari native path.
|
|
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',
|
|
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') {
|
|
// Simulate Safari — window.HLS exists but isSupported() returns false.
|
|
// viewer.js then falls through to video.src = manifestUrl (native HLS).
|
|
win.Hls = class MockHLS_Native {
|
|
static isSupported() {
|
|
return false; // Safari has native HLS, Hls.js is not needed
|
|
}
|
|
};
|
|
}
|
|
|
|
// Track console output
|
|
const consoleLogs = [];
|
|
const consoleErrors = [];
|
|
const origConsole = { ...win.console };
|
|
win.console = {
|
|
...origConsole,
|
|
log: (...args) => consoleLogs.push(args.map(String).join(' ')),
|
|
error: (...args) => {
|
|
consoleErrors.push(args.map(String).join(' '));
|
|
origConsole.error.apply(origConsole, args);
|
|
},
|
|
warn: (...args) => {
|
|
consoleLogs.push(args.map(String).join(' '));
|
|
origConsole.warn.apply(origConsole, args);
|
|
},
|
|
};
|
|
|
|
return { dom, win, storage, consoleLogs, consoleErrors, hlsCalls };
|
|
}
|
|
|
|
describe('Integration: viewer.js with HLS entries', () => {
|
|
it('should render HLS video entries and use Hls.js to play HLS streams', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_viewer_1';
|
|
storageData[viewerId] = [
|
|
{
|
|
url: 'https://example.com/stream',
|
|
src: 'https://example.com/stream',
|
|
hlsManifest: 'https://cdn.example.com/live/playlist.m3u8',
|
|
mediaType: 'hls',
|
|
poster: 'https://example.com/thumb.jpg',
|
|
tab: 'Live Stream',
|
|
},
|
|
{
|
|
url: 'https://example.com/video.mp4',
|
|
src: 'https://example.com/video.mp4',
|
|
mediaType: 'direct',
|
|
tab: 'Regular Video',
|
|
},
|
|
];
|
|
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=2`;
|
|
const { dom, consoleLogs, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
dom.window.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const panels = dom.window.document.querySelectorAll('.panel');
|
|
if (panels.length !== 4) {
|
|
reject(new Error(`Expected 4 panels (2x2 grid), got ${panels.length}`));
|
|
return;
|
|
}
|
|
|
|
const videos = dom.window.document.querySelectorAll('video');
|
|
if (videos.length < 2) {
|
|
reject(new Error(`Expected at least 2 video elements, got ${videos.length}`));
|
|
return;
|
|
}
|
|
|
|
// Status overlay
|
|
const overlay = dom.window.document.getElementById('viewer-status');
|
|
if (!overlay || !overlay.textContent.includes('2 video')) {
|
|
reject(new Error(`Missing/incorrect status overlay: "${overlay ? overlay.textContent : 'null'}"`));
|
|
return;
|
|
}
|
|
|
|
// HLS detection in console logs
|
|
const hlsLogs = consoleLogs.filter(
|
|
(l) => l.includes('HLS detected') || l.includes('HLS')
|
|
);
|
|
if (hlsLogs.length === 0) {
|
|
reject(new Error(`No HLS detection in logs. Logs: ${consoleLogs.join(' | ')}`));
|
|
return;
|
|
}
|
|
|
|
// Verify Hls.js was actually instantiated for the HLS entry
|
|
if (hlsCalls.instances.length < 1) {
|
|
reject(new Error('Expected at least 1 HLS instance, got 0'));
|
|
return;
|
|
}
|
|
|
|
// Verify the HLS instance was attached to a video and loaded the manifest
|
|
const hlsInst = hlsCalls.instances[0];
|
|
if (!hlsInst.source || !hlsInst.source.includes('m3u8')) {
|
|
reject(new Error(`HLS source not set to m3u8 URL: ${hlsInst.source}`));
|
|
return;
|
|
}
|
|
if (!hlsInst.media || hlsInst.media.tagName !== 'VIDEO') {
|
|
reject(new Error('HLS not attached to a <video> element'));
|
|
return;
|
|
}
|
|
|
|
resolve();
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should handle legacy entries (no HLS fields) with standard playback', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_legacy';
|
|
storageData[viewerId] = [
|
|
{ url: 'https://example.com/video.mp4', src: 'https://example.com/video.mp4', tab: 'Old Video' },
|
|
'https://example.com/another.mp4',
|
|
];
|
|
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=2`;
|
|
const { dom, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
dom.window.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const videos = dom.window.document.querySelectorAll('video');
|
|
if (videos.length < 2) {
|
|
reject(new Error(`Expected at least 2 video elements, got ${videos.length}`));
|
|
return;
|
|
}
|
|
|
|
// First video should have src set directly (standard playback)
|
|
const video0Src = videos[0].getAttribute('src');
|
|
if (!video0Src || !video0Src.includes('example.com')) {
|
|
reject(new Error(`Video 0 src not set: ${video0Src}`));
|
|
return;
|
|
}
|
|
|
|
// No HLS instances should have been created
|
|
if (hlsCalls.instances.length > 0) {
|
|
reject(new Error(`Expected 0 HLS instances for legacy entries, got ${hlsCalls.instances.length}`));
|
|
return;
|
|
}
|
|
|
|
resolve();
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should show "No viewer ID provided" when id param is missing', () => {
|
|
const { dom } = createBrowserEnv();
|
|
|
|
dom.window.eval(viewerSource);
|
|
|
|
const panels = dom.window.document.getElementById('panels');
|
|
if (!panels.innerHTML.includes('No viewer ID provided')) {
|
|
throw new Error(`Expected "No viewer ID provided", got: ${panels.innerHTML}`);
|
|
}
|
|
});
|
|
|
|
it('should handle empty video list gracefully', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_empty';
|
|
storageData[viewerId] = [];
|
|
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=2`;
|
|
const { dom } = createBrowserEnv({ storageData, url });
|
|
|
|
dom.window.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const panels = dom.window.document.querySelectorAll('.panel');
|
|
if (panels.length !== 4) {
|
|
reject(new Error(`Expected 4 empty panels, got ${panels.length}`));
|
|
return;
|
|
}
|
|
const emptyPanels = dom.window.document.querySelectorAll('.panel.empty');
|
|
if (emptyPanels.length !== 4) {
|
|
reject(new Error(`Expected 4 empty panels, got ${emptyPanels.length}`));
|
|
return;
|
|
}
|
|
resolve();
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should render correct grid size based on grid param', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_grid3';
|
|
storageData[viewerId] = [{ url: 'https://example.com/1.mp4' }];
|
|
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=3`;
|
|
const { dom } = createBrowserEnv({ storageData, url });
|
|
|
|
dom.window.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const panels = dom.window.document.querySelectorAll('.panel');
|
|
if (panels.length !== 9) {
|
|
reject(new Error(`Expected 9 panels (3x3), got ${panels.length}`));
|
|
return;
|
|
}
|
|
resolve();
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should render tag labels when tags param is provided', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_tags';
|
|
storageData[viewerId] = [
|
|
{ url: 'https://example.com/1.mp4', tab: 'Stream 1' },
|
|
{ url: 'https://example.com/2.mp4', tab: 'Stream 2' },
|
|
];
|
|
|
|
const tags = ['Live Stream 1', 'Live Stream 2'];
|
|
const tagsParam = encodeURIComponent(JSON.stringify(tags));
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=2&tags=${tagsParam}`;
|
|
const { dom } = createBrowserEnv({ storageData, url });
|
|
|
|
dom.window.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const labels = dom.window.document.querySelectorAll('.panel-label');
|
|
if (labels.length < 2) {
|
|
reject(new Error(`Expected 2 labels, got ${labels.length}`));
|
|
return;
|
|
}
|
|
if (labels[0].textContent !== 'Live Stream 1') {
|
|
reject(new Error(`Label 0 wrong: "${labels[0].textContent}"`));
|
|
return;
|
|
}
|
|
resolve();
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should handle mixed HLS and direct entries in same grid', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_mixed';
|
|
storageData[viewerId] = [
|
|
{
|
|
url: 'https://example.com/hls',
|
|
hlsManifest: 'https://cdn.example.com/hls/stream.m3u8',
|
|
mediaType: 'hls',
|
|
tab: 'HLS',
|
|
},
|
|
{
|
|
url: 'https://example.com/direct.mp4',
|
|
src: 'https://example.com/direct.mp4',
|
|
mediaType: 'direct',
|
|
tab: 'MP4',
|
|
},
|
|
{ url: 'https://example.com/legacy.mp4', tab: 'Legacy' },
|
|
];
|
|
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=2`;
|
|
const { dom, consoleLogs, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
dom.window.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const videos = dom.window.document.querySelectorAll('video');
|
|
if (videos.length < 3) {
|
|
reject(new Error(`Expected at least 3 videos, got ${videos.length}`));
|
|
return;
|
|
}
|
|
|
|
// HLS detection in logs
|
|
const hlsLogs = consoleLogs.filter(
|
|
(l) => l.includes('HLS detected') || l.includes('HLS')
|
|
);
|
|
if (hlsLogs.length === 0) {
|
|
reject(new Error(`No HLS logs. All: ${consoleLogs.join(' | ')}`));
|
|
return;
|
|
}
|
|
|
|
// Only one HLS instance (for the HLS entry)
|
|
if (hlsCalls.instances.length !== 1) {
|
|
reject(new Error(`Expected 1 HLS instance, got ${hlsCalls.instances.length}`));
|
|
return;
|
|
}
|
|
|
|
// Direct entry video should have src set directly
|
|
const directVideo = videos[1];
|
|
if (!directVideo.getAttribute('src') || !directVideo.getAttribute('src').includes('direct.mp4')) {
|
|
reject(new Error(`Direct video src wrong: ${directVideo.getAttribute('src')}`));
|
|
return;
|
|
}
|
|
|
|
resolve();
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should fall back to native playback when HLS is not supported (Safari mode)', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_safari';
|
|
storageData[viewerId] = [
|
|
{
|
|
url: '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 { dom } = createBrowserEnv({ storageData, url, hlsMode: 'native' });
|
|
|
|
dom.window.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const videos = dom.window.document.querySelectorAll('video');
|
|
if (videos.length !== 1) {
|
|
reject(new Error(`Expected 1 video, got ${videos.length}`));
|
|
return;
|
|
}
|
|
|
|
// In native mode, video.src should be set to the manifest URL directly
|
|
const src = videos[0].getAttribute('src');
|
|
if (!src || !src.includes('m3u8')) {
|
|
reject(new Error(`Expected src to be m3u8 URL, got: ${src}`));
|
|
return;
|
|
}
|
|
|
|
resolve();
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
}, 500);
|
|
});
|
|
});
|
|
|
|
it('should fall back to src when mediaType=hls but hlsManifest is missing', () => {
|
|
const storageData = {};
|
|
const viewerId = 'test_missing_manifest';
|
|
storageData[viewerId] = [
|
|
{
|
|
url: 'https://example.com/page',
|
|
src: 'https://example.com/video.mp4',
|
|
mediaType: 'hls',
|
|
// hlsManifest intentionally missing
|
|
},
|
|
];
|
|
|
|
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
|
|
const { dom, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
|
|
|
|
dom.window.eval(viewerSource);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
try {
|
|
const videos = dom.window.document.querySelectorAll('video');
|
|
if (videos.length !== 1) {
|
|
reject(new Error(`Expected 1 video, got ${videos.length}`));
|
|
return;
|
|
}
|
|
|
|
// Since hlsManifest is missing, viewer.js should fall back to resolveSrc
|
|
// The src should be set directly (not through HLS)
|
|
const src = videos[0].getAttribute('src');
|
|
if (!src || !src.includes('video.mp4')) {
|
|
reject(new Error(`Expected fallback to src, got: ${src}`));
|
|
return;
|
|
}
|
|
|
|
// No HLS instance should have been created
|
|
if (hlsCalls.instances.length > 0) {
|
|
reject(new Error(`Expected 0 HLS instances, got ${hlsCalls.instances.length}`));
|
|
return;
|
|
}
|
|
|
|
resolve();
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
}, 500);
|
|
});
|
|
});
|
|
});
|