diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d3e251c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test diff --git a/__tests__/data_flow.test.mjs b/__tests__/data_flow.test.mjs new file mode 100644 index 0000000..eb88042 --- /dev/null +++ b/__tests__/data_flow.test.mjs @@ -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'); + } + }); +}); diff --git a/__tests__/run.mjs b/__tests__/run.mjs new file mode 100644 index 0000000..1d4b674 --- /dev/null +++ b/__tests__/run.mjs @@ -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); diff --git a/__tests__/runner.mjs b/__tests__/runner.mjs new file mode 100644 index 0000000..7d50bd6 --- /dev/null +++ b/__tests__/runner.mjs @@ -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 }; +} diff --git a/__tests__/schema.test.mjs b/__tests__/schema.test.mjs new file mode 100644 index 0000000..5972686 --- /dev/null +++ b/__tests__/schema.test.mjs @@ -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)}`); + } + }); +}); diff --git a/__tests__/viewer_integration.test.mjs b/__tests__/viewer_integration.test.mjs new file mode 100644 index 0000000..2d2fd43 --- /dev/null +++ b/__tests__/viewer_integration.test.mjs @@ -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 = `
`; + + 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