Files
robot 542c5786be
CI / test (pull_request) Successful in 23s
feat: add HLS playback support with hls.js and modular viewer architecture
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)
2026-08-12 14:13:55 +00:00

127 lines
3.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('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)}`);
}
});
});