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)
189 lines
6.9 KiB
JavaScript
189 lines
6.9 KiB
JavaScript
import { describe, it } from './runner.mjs';
|
|
import Ajv from 'ajv';
|
|
import addFormats from 'ajv-formats';
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
const schema = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../src/viewer/schema.json'), 'utf8'));
|
|
|
|
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');
|
|
}
|
|
});
|
|
});
|