- viewer.js: dynamic hls.js loading, HLS-aware panel creation, error handling - schema.json: hlsManifest and mediaType fields (optional, backward compatible) - types.ts: VideoEntry interface with MediaType type - isHls() null guard fix - tests: 39 tests (schema, viewer units, data flow, integration) - CI: GitHub/Gitea Actions workflow (npm test on push/PR) - deps: ajv, ajv-formats, jsdom for testing
This commit is contained in:
1 parent
baa72a4c33
commit
90c3425200
12 files changed
+2295
-27
No files matched your search
@@ -0,0 +1,185 @@
|
||||
import { describe, it } from './runner.mjs';
|
||||
import Ajv from 'ajv';
|
||||
import addFormats from 'ajv-formats';
|
||||
import schema from '../src/viewer/schema.json' with { type: 'json' };
|
||||
|
||||
const ajv = new Ajv({ strict: false });
|
||||
addFormats(ajv);
|
||||
const validate = ajv.compile(schema);
|
||||
|
||||
describe('Data flow: popup -> storage -> viewer', () => {
|
||||
it('should pass a scraped HLS entry through schema validation', () => {
|
||||
// Simulate popup scraping an HLS stream
|
||||
const scrapedEntry = {
|
||||
url: 'https://stream.example.com/watch',
|
||||
src: 'https://cdn.example.com/live/playlist.m3u8',
|
||||
poster: 'https://cdn.example.com/live/thumb.jpg',
|
||||
tab: 'Live Broadcast',
|
||||
hlsManifest: 'https://cdn.example.com/live/playlist.m3u8',
|
||||
mediaType: 'hls',
|
||||
};
|
||||
|
||||
// Validate against schema (as storage layer would)
|
||||
const valid = validate(scrapedEntry);
|
||||
if (!valid) {
|
||||
throw new Error(`Scraped HLS entry fails schema: ${JSON.stringify(validate.errors)}`);
|
||||
}
|
||||
|
||||
// Verify the viewer would detect it as HLS
|
||||
const isHls = (entry) => typeof entry === 'object' && entry.mediaType === 'hls';
|
||||
if (!isHls(scrapedEntry)) {
|
||||
throw new Error('Scraped HLS entry not detected as HLS by viewer');
|
||||
}
|
||||
|
||||
// Verify the viewer would resolve the manifest
|
||||
const resolveHlsManifest = (entry) => {
|
||||
if (typeof entry === 'object' && entry.hlsManifest) return entry.hlsManifest;
|
||||
return null;
|
||||
};
|
||||
const manifest = resolveHlsManifest(scrapedEntry);
|
||||
if (manifest !== 'https://cdn.example.com/live/playlist.m3u8') {
|
||||
throw new Error(`Manifest URL wrong: ${manifest}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should pass a scraped direct entry through schema validation', () => {
|
||||
const scrapedEntry = {
|
||||
url: 'https://video.example.com/watch',
|
||||
src: 'https://cdn.example.com/video.mp4',
|
||||
poster: 'https://cdn.example.com/thumb.jpg',
|
||||
tab: 'Video Page',
|
||||
};
|
||||
|
||||
const valid = validate(scrapedEntry);
|
||||
if (!valid) {
|
||||
throw new Error(`Scraped direct entry fails schema: ${JSON.stringify(validate.errors)}`);
|
||||
}
|
||||
|
||||
const isHls = (entry) => typeof entry === 'object' && entry.mediaType === 'hls';
|
||||
if (isHls(scrapedEntry)) {
|
||||
throw new Error('Direct entry falsely detected as HLS');
|
||||
}
|
||||
|
||||
const resolveSrc = (entry) => {
|
||||
if (typeof entry === 'string') return entry;
|
||||
return entry.src || entry.url || '';
|
||||
};
|
||||
const src = resolveSrc(scrapedEntry);
|
||||
if (src !== 'https://cdn.example.com/video.mp4') {
|
||||
throw new Error(`Src URL wrong: ${src}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should survive a round-trip: storage serialization -> deserialization', () => {
|
||||
const originalEntry = {
|
||||
url: 'https://example.com/stream',
|
||||
src: 'https://cdn.example.com/stream.m3u8',
|
||||
poster: 'https://example.com/thumb.jpg',
|
||||
tab: 'HLS Stream',
|
||||
hlsManifest: 'https://cdn.example.com/stream.m3u8',
|
||||
mediaType: 'hls',
|
||||
};
|
||||
|
||||
// Simulate storage round-trip (JSON serialization)
|
||||
const serialized = JSON.stringify(originalEntry);
|
||||
const deserialized = JSON.parse(serialized);
|
||||
|
||||
// Validate after deserialization
|
||||
const valid = validate(deserialized);
|
||||
if (!valid) {
|
||||
throw new Error(`Round-tripped entry fails schema: ${JSON.stringify(validate.errors)}`);
|
||||
}
|
||||
|
||||
// Verify fields are intact
|
||||
if (deserialized.mediaType !== 'hls') {
|
||||
throw new Error('mediaType lost during round-trip');
|
||||
}
|
||||
if (deserialized.hlsManifest !== 'https://cdn.example.com/stream.m3u8') {
|
||||
throw new Error('hlsManifest lost during round-trip');
|
||||
}
|
||||
|
||||
// Verify viewer still detects it correctly
|
||||
const isHls = (entry) => typeof entry === 'object' && entry.mediaType === 'hls';
|
||||
if (!isHls(deserialized)) {
|
||||
throw new Error('Round-tripped entry not detected as HLS');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle batch entries from popup -> storage -> viewer pipeline', () => {
|
||||
// Simulate popup storing multiple entries
|
||||
const popupEntries = [
|
||||
{ url: 'https://example.com/1', src: 'https://cdn.example.com/1.mp4', tab: 'Tab 1' },
|
||||
{
|
||||
url: 'https://example.com/2',
|
||||
src: 'https://cdn.example.com/2.m3u8',
|
||||
hlsManifest: 'https://cdn.example.com/2.m3u8',
|
||||
mediaType: 'hls',
|
||||
tab: 'Tab 2',
|
||||
},
|
||||
{ url: 'https://example.com/3', src: 'https://cdn.example.com/3.webm', mediaType: 'direct', tab: 'Tab 3' },
|
||||
];
|
||||
|
||||
// Validate all entries
|
||||
for (const [i, entry] of popupEntries.entries()) {
|
||||
const valid = validate(entry);
|
||||
if (!valid) {
|
||||
throw new Error(`Entry ${i} fails schema: ${JSON.stringify(validate.errors)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate storage round-trip
|
||||
const serialized = JSON.stringify(popupEntries);
|
||||
const deserialized = JSON.parse(serialized);
|
||||
|
||||
// Verify viewer can process all entries
|
||||
const isHls = (entry) => typeof entry === 'object' && entry.mediaType === 'hls';
|
||||
const resolveSrc = (entry) => {
|
||||
if (typeof entry === 'string') return entry;
|
||||
return entry.src || entry.url || '';
|
||||
};
|
||||
const resolveHlsManifest = (entry) => {
|
||||
if (typeof entry === 'object' && entry.hlsManifest) return entry.hlsManifest;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Entry 0: legacy (no mediaType)
|
||||
if (isHls(deserialized[0])) throw new Error('Entry 0 should not be HLS');
|
||||
if (resolveSrc(deserialized[0]) !== 'https://cdn.example.com/1.mp4') throw new Error('Entry 0 src wrong');
|
||||
|
||||
// Entry 1: HLS
|
||||
if (!isHls(deserialized[1])) throw new Error('Entry 1 should be HLS');
|
||||
if (resolveHlsManifest(deserialized[1]) !== 'https://cdn.example.com/2.m3u8') throw new Error('Entry 1 manifest wrong');
|
||||
|
||||
// Entry 2: explicit direct
|
||||
if (isHls(deserialized[2])) throw new Error('Entry 2 should not be HLS');
|
||||
if (resolveSrc(deserialized[2]) !== 'https://cdn.example.com/3.webm') throw new Error('Entry 2 src wrong');
|
||||
});
|
||||
|
||||
it('should reject malformed entries that could crash the viewer', () => {
|
||||
const malformedEntries = [
|
||||
{ hlsManifest: 'https://example.com/stream.m3u8' }, // missing url
|
||||
{ url: 'https://example.com/video.mp4', mediaType: 'invalid' }, // invalid mediaType
|
||||
{ url: 'not-a-url' }, // technically valid per schema (format: uri not enforced by Ajv without custom formats)
|
||||
];
|
||||
|
||||
for (const [i, entry] of malformedEntries.entries()) {
|
||||
const valid = validate(entry);
|
||||
if (!valid && i < 2) {
|
||||
// Expected failures for entries 0 and 1
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Entry 0 (missing url) should be rejected
|
||||
const entry0Valid = validate(malformedEntries[0]);
|
||||
if (entry0Valid) {
|
||||
throw new Error('Entry without url should be rejected');
|
||||
}
|
||||
|
||||
// Entry 1 (invalid mediaType) should be rejected
|
||||
const entry1Valid = validate(malformedEntries[1]);
|
||||
if (entry1Valid) {
|
||||
throw new Error('Entry with invalid mediaType should be rejected');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Test entry point — imports all test suites then runs them.
|
||||
*/
|
||||
import './schema.test.mjs';
|
||||
import './viewer_units.test.mjs';
|
||||
import './data_flow.test.mjs';
|
||||
import './viewer_integration.test.mjs';
|
||||
import { run } from './runner.mjs';
|
||||
|
||||
const result = await run();
|
||||
process.exit(result.totalFailed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Minimal test runner — describe/it with timing and summary.
|
||||
* No external dependencies. Import this from test files.
|
||||
*/
|
||||
|
||||
const suites = [];
|
||||
let currentSuite = null;
|
||||
let totalTests = 0;
|
||||
let totalPassed = 0;
|
||||
let totalFailed = 0;
|
||||
const failures = [];
|
||||
|
||||
export function describe(name, fn) {
|
||||
currentSuite = { name, tests: [], passed: 0, failed: 0, duration: 0 };
|
||||
suites.push(currentSuite);
|
||||
fn();
|
||||
currentSuite = null;
|
||||
}
|
||||
|
||||
export function it(name, fn) {
|
||||
if (!currentSuite) {
|
||||
throw new Error(`it() called outside describe(). Test: "${name}"`);
|
||||
}
|
||||
currentSuite.tests.push({ name, fn, async: typeof fn === 'function' });
|
||||
}
|
||||
|
||||
// Run all suites and print results
|
||||
export async function run() {
|
||||
let globalStart = Date.now();
|
||||
|
||||
for (const suite of suites) {
|
||||
const suiteStart = Date.now();
|
||||
process.stdout.write(`\n${suite.name}\n${'─'.repeat(suite.name.length + 2)}\n`);
|
||||
|
||||
for (const test of suite.tests) {
|
||||
totalTests++;
|
||||
const testStart = Date.now();
|
||||
try {
|
||||
const result = test.fn();
|
||||
if (result instanceof Promise) {
|
||||
await result;
|
||||
}
|
||||
const ms = Date.now() - testStart;
|
||||
totalPassed++;
|
||||
suite.passed++;
|
||||
process.stdout.write(` ✓ ${test.name} (${ms}ms)\n`);
|
||||
} catch (err) {
|
||||
const ms = Date.now() - testStart;
|
||||
totalFailed++;
|
||||
suite.failed++;
|
||||
failures.push({ suite: suite.name, test: test.name, error: err.message, stack: err.stack });
|
||||
process.stdout.write(` ✗ ${test.name} (${ms}ms)\n`);
|
||||
process.stdout.write(` → ${err.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
suite.duration = Date.now() - suiteStart;
|
||||
}
|
||||
|
||||
const totalMs = Date.now() - globalStart;
|
||||
|
||||
process.stdout.write(`\n${'='.repeat(60)}\n`);
|
||||
process.stdout.write(`Results: ${totalPassed}/${totalTests} passed`);
|
||||
if (totalFailed > 0) {
|
||||
process.stdout.write(` | ${totalFailed} FAILED`);
|
||||
}
|
||||
process.stdout.write(` | ${totalMs}ms\n`);
|
||||
process.stdout.write(`${'='.repeat(60)}\n`);
|
||||
|
||||
if (failures.length > 0) {
|
||||
process.stdout.write(`\nFailures:\n`);
|
||||
for (const f of failures) {
|
||||
process.stdout.write(` [${f.suite}] ${f.test}\n`);
|
||||
process.stdout.write(` Error: ${f.error}\n`);
|
||||
process.stdout.write(` ${f.stack.split('\n')[1].trim()}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
return { totalTests, totalPassed, totalFailed, totalMs, failures };
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it } from './runner.mjs';
|
||||
import Ajv from 'ajv';
|
||||
import addFormats from 'ajv-formats';
|
||||
import schema from '../src/viewer/schema.json' with { type: 'json' };
|
||||
|
||||
const ajv = new Ajv({ strict: false });
|
||||
addFormats(ajv);
|
||||
const validate = ajv.compile(schema);
|
||||
|
||||
describe('Schema: VideoEntry with HLS fields', () => {
|
||||
it('should validate a legacy entry (url only)', () => {
|
||||
const entry = { url: 'https://example.com/video.mp4' };
|
||||
const valid = validate(entry);
|
||||
if (!valid) {
|
||||
throw new Error(`Legacy entry rejected: ${JSON.stringify(validate.errors)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate a full legacy entry (all original fields)', () => {
|
||||
const entry = {
|
||||
url: 'https://example.com/video.mp4',
|
||||
src: 'https://example.com/video.mp4',
|
||||
poster: 'https://example.com/thumb.jpg',
|
||||
tab: 'Video Page',
|
||||
};
|
||||
const valid = validate(entry);
|
||||
if (!valid) {
|
||||
throw new Error(`Full legacy entry rejected: ${JSON.stringify(validate.errors)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate an HLS entry with hlsManifest and mediaType', () => {
|
||||
const entry = {
|
||||
url: 'https://example.com/playlist.m3u8',
|
||||
src: 'https://example.com/playlist.m3u8',
|
||||
hlsManifest: 'https://cdn.example.com/stream.m3u8',
|
||||
mediaType: 'hls',
|
||||
poster: 'https://example.com/thumb.jpg',
|
||||
tab: 'HLS Stream Page',
|
||||
};
|
||||
const valid = validate(entry);
|
||||
if (!valid) {
|
||||
throw new Error(`HLS entry rejected: ${JSON.stringify(validate.errors)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate an HLS entry with only hlsManifest (mediaType omitted)', () => {
|
||||
const entry = {
|
||||
url: 'https://example.com/page',
|
||||
hlsManifest: 'https://cdn.example.com/stream.m3u8',
|
||||
};
|
||||
const valid = validate(entry);
|
||||
if (!valid) {
|
||||
throw new Error(`HLS entry with only hlsManifest rejected: ${JSON.stringify(validate.errors)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate an entry with mediaType=direct', () => {
|
||||
const entry = {
|
||||
url: 'https://example.com/video.mp4',
|
||||
mediaType: 'direct',
|
||||
};
|
||||
const valid = validate(entry);
|
||||
if (!valid) {
|
||||
throw new Error(`Direct entry rejected: ${JSON.stringify(validate.errors)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject entries without url', () => {
|
||||
const entry = {
|
||||
hlsManifest: 'https://cdn.example.com/stream.m3u8',
|
||||
mediaType: 'hls',
|
||||
};
|
||||
const valid = validate(entry);
|
||||
if (valid) {
|
||||
throw new Error('Entry without url should be rejected but was accepted');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid mediaType values', () => {
|
||||
const entry = {
|
||||
url: 'https://example.com/video.mp4',
|
||||
mediaType: 'dash',
|
||||
};
|
||||
const valid = validate(entry);
|
||||
if (valid) {
|
||||
throw new Error('Invalid mediaType "dash" should be rejected but was accepted');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject additional properties', () => {
|
||||
const entry = {
|
||||
url: 'https://example.com/video.mp4',
|
||||
customField: 'value',
|
||||
};
|
||||
const valid = validate(entry);
|
||||
if (valid) {
|
||||
throw new Error('Entry with additional properties should be rejected but was accepted');
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate poster as null', () => {
|
||||
const entry = {
|
||||
url: 'https://example.com/video.mp4',
|
||||
poster: null,
|
||||
};
|
||||
const valid = validate(entry);
|
||||
if (!valid) {
|
||||
throw new Error(`Entry with null poster rejected: ${JSON.stringify(validate.errors)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate hlsManifest as URI format', () => {
|
||||
const entry = {
|
||||
url: 'https://example.com/page',
|
||||
hlsManifest: 'https://stream.example.com/hls/playlist.m3u8',
|
||||
};
|
||||
const valid = validate(entry);
|
||||
if (!valid) {
|
||||
throw new Error(`Valid hlsManifest URI rejected: ${JSON.stringify(validate.errors)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,511 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, it } from './runner.mjs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
describe('Viewer: resolveSrc()', () => {
|
||||
// We extract the pure logic functions from viewer.js for unit testing.
|
||||
// Since viewer.js uses browser globals, we test the logic independently.
|
||||
const resolveSrc = (entry) => {
|
||||
if (typeof entry === 'string') return entry;
|
||||
return entry.src || entry.url || '';
|
||||
};
|
||||
|
||||
it('should resolve string entries directly', () => {
|
||||
const result = resolveSrc('https://example.com/video.mp4');
|
||||
if (result !== 'https://example.com/video.mp4') {
|
||||
throw new Error(`Expected original string, got: ${result}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should prefer src over url in object entries', () => {
|
||||
const entry = { url: 'https://example.com/page', src: 'https://cdn.example.com/stream.mp4' };
|
||||
const result = resolveSrc(entry);
|
||||
if (result !== 'https://cdn.example.com/stream.mp4') {
|
||||
throw new Error(`Expected src, got: ${result}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should fall back to url when src is missing', () => {
|
||||
const entry = { url: 'https://example.com/video.mp4' };
|
||||
const result = resolveSrc(entry);
|
||||
if (result !== 'https://example.com/video.mp4') {
|
||||
throw new Error(`Expected url, got: ${result}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return empty string for empty object', () => {
|
||||
const entry = {};
|
||||
const result = resolveSrc(entry);
|
||||
if (result !== '') {
|
||||
throw new Error(`Expected empty string, got: ${result}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Viewer: isHls()', () => {
|
||||
const isHls = (entry) => {
|
||||
return entry !== null && typeof entry === 'object' && entry.mediaType === 'hls';
|
||||
};
|
||||
|
||||
it('should detect HLS entries with mediaType=hls', () => {
|
||||
const entry = { url: 'https://example.com/stream', mediaType: 'hls', hlsManifest: 'https://cdn.example.com/stream.m3u8' };
|
||||
if (!isHls(entry)) {
|
||||
throw new Error('HLS entry not detected');
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT detect direct mediaType', () => {
|
||||
const entry = { url: 'https://example.com/video.mp4', mediaType: 'direct' };
|
||||
if (isHls(entry)) {
|
||||
throw new Error('Direct entry falsely detected as HLS');
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT detect entries without mediaType (backward compat)', () => {
|
||||
const entry = { url: 'https://example.com/video.mp4' };
|
||||
if (isHls(entry)) {
|
||||
throw new Error('Legacy entry without mediaType falsely detected as HLS');
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT detect string entries', () => {
|
||||
if (isHls('https://example.com/video.mp4')) {
|
||||
throw new Error('String entry falsely detected as HLS');
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT detect null entries (null guard)', () => {
|
||||
// With the null guard, isHls(null) safely returns false instead of throwing
|
||||
if (isHls(null)) {
|
||||
throw new Error('Null entry falsely detected as HLS');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Viewer: resolveHlsManifest()', () => {
|
||||
const resolveHlsManifest = (entry) => {
|
||||
if (typeof entry === 'object' && entry.hlsManifest) {
|
||||
return entry.hlsManifest;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
it('should return hlsManifest URL when present', () => {
|
||||
const entry = { url: 'https://example.com/page', hlsManifest: 'https://cdn.example.com/stream.m3u8' };
|
||||
const result = resolveHlsManifest(entry);
|
||||
if (result !== 'https://cdn.example.com/stream.m3u8') {
|
||||
throw new Error(`Expected hlsManifest URL, got: ${result}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return null when hlsManifest is missing', () => {
|
||||
const entry = { url: 'https://example.com/video.mp4' };
|
||||
const result = resolveHlsManifest(entry);
|
||||
if (result !== null) {
|
||||
throw new Error(`Expected null, got: ${result}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return null for string entries', () => {
|
||||
const result = resolveHlsManifest('https://example.com/video.mp4');
|
||||
if (result !== null) {
|
||||
throw new Error(`Expected null for string, got: ${result}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Viewer: DOM setup via jsdom', () => {
|
||||
it('should create panels container and video elements', () => {
|
||||
const dom = new JSDOM(`
|
||||
<!DOCTYPE html>
|
||||
<html><body><div id="panels"></div></body></html>
|
||||
`);
|
||||
const panelsEl = dom.window.document.getElementById('panels');
|
||||
if (!panelsEl) {
|
||||
throw new Error('panels element not found');
|
||||
}
|
||||
|
||||
// Simulate what viewer.js does: create panels
|
||||
const panel = dom.window.document.createElement('div');
|
||||
panel.classList.add('panel');
|
||||
const video = dom.window.document.createElement('video');
|
||||
video.autoplay = true;
|
||||
video.muted = true;
|
||||
video.loop = true;
|
||||
video.playsInline = true;
|
||||
video.src = 'https://example.com/video.mp4';
|
||||
panel.appendChild(video);
|
||||
panelsEl.appendChild(panel);
|
||||
|
||||
const videos = panelsEl.querySelectorAll('video');
|
||||
if (videos.length !== 1) {
|
||||
throw new Error(`Expected 1 video, got ${videos.length}`);
|
||||
}
|
||||
if (!videos[0].autoplay) throw new Error('autoplay not set');
|
||||
if (!videos[0].muted) throw new Error('muted not set');
|
||||
if (!videos[0].loop) throw new Error('loop not set');
|
||||
if (!videos[0].playsInline) throw new Error('playsInline not set');
|
||||
});
|
||||
|
||||
it('should create an NxN grid layout', () => {
|
||||
const dom = new JSDOM(`
|
||||
<!DOCTYPE html>
|
||||
<html><body><div id="panels"></div></body></html>
|
||||
`);
|
||||
const panelsEl = dom.window.document.getElementById('panels');
|
||||
const gridN = 3;
|
||||
|
||||
for (let i = 0; i < gridN * gridN; i++) {
|
||||
const panel = dom.window.document.createElement('div');
|
||||
panel.classList.add('panel');
|
||||
panelsEl.appendChild(panel);
|
||||
}
|
||||
|
||||
panelsEl.style.gridTemplateColumns = `repeat(${gridN}, 1fr)`;
|
||||
panelsEl.style.gridTemplateRows = `repeat(${gridN}, 1fr)`;
|
||||
|
||||
const panels = panelsEl.querySelectorAll('.panel');
|
||||
if (panels.length !== 9) {
|
||||
throw new Error(`Expected 9 panels, got ${panels.length}`);
|
||||
}
|
||||
if (panelsEl.style.gridTemplateColumns !== 'repeat(3, 1fr)') {
|
||||
throw new Error('Grid columns not set correctly');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle empty panels correctly', () => {
|
||||
const dom = new JSDOM(`
|
||||
<!DOCTYPE html>
|
||||
<html><body><div id="panels"></div></body></html>
|
||||
`);
|
||||
const panelsEl = dom.window.document.getElementById('panels');
|
||||
|
||||
const panel = dom.window.document.createElement('div');
|
||||
panel.classList.add('panel');
|
||||
panel.classList.add('empty');
|
||||
panelsEl.appendChild(panel);
|
||||
|
||||
const emptyPanels = panelsEl.querySelectorAll('.panel.empty');
|
||||
if (emptyPanels.length !== 1) {
|
||||
throw new Error(`Expected 1 empty panel, got ${emptyPanels.length}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user