const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const vm = require('node:vm'); const { spawn } = require('node:child_process'); const Database = require('better-sqlite3'); async function startApp(t) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodecast-live-catalog-')); fs.writeFileSync(path.join(dir, 'db.json'), JSON.stringify({ sources: [ { id: 1, type: 'm3u', name: 'Primary', url: 'http://127.0.0.1:1/unused.m3u', enabled: true }, { id: 2, type: 'xtream', name: 'Secondary', url: 'http://127.0.0.1:1', enabled: true }, { id: 3, type: 'm3u', name: 'Disabled', url: 'http://127.0.0.1:1/unused.m3u', enabled: false }, { id: 4, type: 'm3u', name: 'Tertiary', url: 'http://127.0.0.1:1/unused.m3u', enabled: true } ], hiddenItems: [], favorites: [], users: [], nextId: 5 })); const child = spawn(process.execPath, ['server/index.js'], { cwd: path.join(__dirname, '..'), env: { ...process.env, PORT: '0', NODECAST_DATA_DIR: dir, NODECAST_DISABLE_BACKGROUND_JOBS: 'true', JWT_SECRET: '', SESSION_SECRET: '', OIDC_ISSUER_URL: '', AUTH_COOKIE_SECURE: 'false' }, stdio: ['ignore', 'pipe', 'pipe'] }); t.after(async () => { if (child.exitCode === null) { const closed = new Promise(resolve => child.once('close', resolve)); child.kill(); await closed; } fs.rmSync(dir, { recursive: true, force: true }); }); let output = ''; const base = await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`Server startup timed out: ${output}`)), 15000); child.stdout.on('data', chunk => { output += chunk; const match = output.match(/server running on http:\/\/localhost:(\d+)/); if (match) { clearTimeout(timer); resolve(`http://127.0.0.1:${match[1]}`); } }); child.stderr.on('data', chunk => { output += chunk; }); child.once('exit', code => { clearTimeout(timer); reject(new Error(`Server exited ${code}: ${output}`)); }); }); const setup = await fetch(`${base}/api/auth/setup`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'Admin', password: 'password123' }) }); assert.equal(setup.status, 201); const setupBody = await setup.json(); const cookie = setup.headers.getSetCookie().find(value => value.startsWith('nodecast_auth_')); return { base, cookie: cookie.split(';')[0], userId: setupBody.user.id, dir }; } async function request(base, cookie, query = {}) { const params = new URLSearchParams(Object.entries(query).filter(([, value]) => value !== undefined)); const response = await fetch(`${base}/api/channels/live-catalog?${params}`, { headers: { Cookie: cookie } }); const text = await response.text(); return { status: response.status, text, bytes: Buffer.byteLength(text), body: JSON.parse(text) }; } async function requestAllPages(base, cookie, query = {}) { const items = []; let cursor; let pageCount = 0; do { const result = await request(base, cookie, { ...query, cursor }); assert.equal(result.status, 200); items.push(...result.body.items); cursor = result.body.page.nextCursor; pageCount++; assert.ok(pageCount < 30, 'cursor pagination did not terminate'); } while (cursor); return items; } function seedCatalog(dir, userId) { const db = new Database(path.join(dir, 'content.db')); const insertCategory = db.prepare(` INSERT INTO categories (id, source_id, category_id, type, name, is_hidden) VALUES (?, ?, ?, 'live', ?, ?) `); insertCategory.run('1:alpha', 1, 'alpha', 'Alpha Group', 0); insertCategory.run('1:beta', 1, 'beta', 'Beta Group', 0); insertCategory.run('1:hidden', 1, 'hidden', 'Hidden Group', 1); insertCategory.run('2:other', 2, 'other', 'Other Group', 0); insertCategory.run('1:key-a', 1, 'key-a', 'A Group', 0); insertCategory.run('1:key-b', 1, 'key-b', 'B Group', 0); insertCategory.run('2:key-a', 2, 'key-a', 'A Group', 0); insertCategory.run('4:key-a', 4, 'key-a', 'A Group', 0); insertCategory.run('4:key-b', 4, 'key-b', 'B Group', 0); const insertItem = db.prepare(` INSERT INTO playlist_items (id, source_id, item_id, type, name, category_id, stream_url, is_hidden, data) VALUES (?, ?, ?, 'live', ?, ?, ?, ?, ?) `); const insert = db.transaction(() => { for (let i = 0; i < 1197; i++) { const id = `primary-${String(i).padStart(4, '0')}`; const categoryId = i < 600 ? 'alpha' : 'beta'; insertItem.run(`1:${id}`, 1, id, `Channel ${String(i).padStart(4, '0')}`, categoryId, `http://streams.test/${id}`, 0, '{}'); } insertItem.run('1:hidden-item', 1, 'hidden-item', 'Hidden Item Channel', 'alpha', 'http://streams.test/hidden-item', 1, '{}'); insertItem.run('1:hidden-group', 1, 'hidden-group', 'Hidden Group Channel', 'hidden', 'http://streams.test/hidden-group', 0, '{}'); insertItem.run('1:needle', 1, 'needle', 'ZZZ Needle Far Away', 'beta', 'http://streams.test/needle', 0, '{}'); for (let i = 0; i < 3; i++) { const id = `secondary-${i}`; insertItem.run(`2:${id}`, 2, id, `Other Channel ${i}`, 'other', `http://streams.test/${id}`, 0, '{}'); } insertItem.run('1:a-head', 1, 'a-head', 'A', 'key-a', 'http://streams.test/a-head', 0, '{}'); insertItem.run('1:a-tie', 1, 'a-tie', 'Tie', 'key-a', 'http://streams.test/a-tie', 0, '{}'); insertItem.run('1:z-tie', 1, 'z-tie', 'Tie', 'key-a', 'http://streams.test/z-tie', 0, '{}'); insertItem.run('2:b-tie', 2, 'b-tie', 'Tie', 'key-a', 'http://streams.test/b-tie', 0, '{}'); insertItem.run('4:c-tie', 4, 'c-tie', 'Tie', 'key-a', 'http://streams.test/c-tie', 0, '{}'); insertItem.run('1:z-last', 1, 'z-last', 'Z', 'key-a', 'http://streams.test/z-last', 0, '{}'); insertItem.run('4:b-1', 4, 'b-1', 'Tie', 'key-b', 'http://streams.test/b-1', 0, '{}'); insertItem.run('4:b-2', 4, 'b-2', 'Tie', 'key-b', 'http://streams.test/b-2', 0, '{}'); db.prepare(`INSERT INTO favorites (user_id, source_id, item_id, item_type) VALUES (?, 1, 'needle', 'channel')`).run(userId); db.prepare(`INSERT INTO favorites (user_id, source_id, item_id, item_type) VALUES (9999, 1, 'primary-0000', 'channel')`).run(); }); insert(); db.close(); } function seedIdentityAndOrderCases(dir) { const db = new Database(path.join(dir, 'content.db')); const insertCategory = db.prepare(` INSERT INTO categories (id, source_id, category_id, type, name, is_hidden) VALUES (?, ?, ?, 'live', ?, 0) `); insertCategory.run('1:shared', 1, 'shared', 'Shared Collision Group'); insertCategory.run('4:shared', 4, 'shared', 'Shared Collision Group'); insertCategory.run('1:eclair-upper', 1, 'eclair-upper', 'Eclair'); insertCategory.run('1:eclair-lower', 1, 'eclair-lower', 'eclair'); insertCategory.run('1:eclair-accent', 1, 'eclair-accent', 'Éclair'); insertCategory.run('4:ws-tab', 4, 'ws-tab', '\tWhitespaceGroup\t'); insertCategory.run('4:ws-ascii', 4, 'ws-ascii', ' WhitespaceGroup '); insertCategory.run('4:ws-unicode', 4, 'ws-unicode', '\u00a0WhitespaceGroup\u00a0'); const insertItem = db.prepare(` INSERT INTO playlist_items (id, source_id, item_id, type, name, category_id, stream_url, is_hidden, data) VALUES (?, ?, ?, 'live', ?, ?, ?, 0, '{}') `); const insert = db.transaction(() => { // Same provider stream identifier in two sources; source-scoped IDs must remain distinct. insertItem.run('1:same-provider-id', 1, 'same-provider-id', 'Shared Collision Channel', 'shared', 'http://streams.test/shared-primary'); insertItem.run('4:same-provider-id', 4, 'same-provider-id', 'Shared Collision Channel', 'shared', 'http://streams.test/shared-tertiary'); // Case-folded ties and an accented group exercise the normalized and binary tie-breaks. for (const [categoryId, suffix] of [['eclair-upper', 'upper'], ['eclair-lower', 'lower'], ['eclair-accent', 'accent']]) { insertItem.run(`1:${suffix}-upper`, 1, `${suffix}-upper`, 'CaseVariant', categoryId, `http://streams.test/${suffix}-upper`); insertItem.run(`1:${suffix}-lower`, 1, `${suffix}-lower`, 'casevariant', categoryId, `http://streams.test/${suffix}-lower`); } for (const [categoryId, groupKind] of [['ws-tab', 'tab'], ['ws-ascii', 'ascii'], ['ws-unicode', 'unicode']]) { insertItem.run(`4:${groupKind}-tab`, 4, `${groupKind}-tab`, '\tWhitespaceProbe\t', categoryId, `http://streams.test/${groupKind}-tab`); insertItem.run(`4:${groupKind}-ascii`, 4, `${groupKind}-ascii`, ' WhitespaceProbe ', categoryId, `http://streams.test/${groupKind}-ascii`); insertItem.run(`4:${groupKind}-unicode`, 4, `${groupKind}-unicode`, '\u00a0WhitespaceProbe\u00a0', categoryId, `http://streams.test/${groupKind}-unicode`); } }); insert(); db.close(); } function clientPageFixture() { const played = []; const context = { window: {}, document: { querySelectorAll: () => [] }, console: { log() {}, warn() {}, error() {} }, setTimeout, clearTimeout, AbortController, Promise }; vm.createContext(context); vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/js/components/ChannelList.js'), 'utf8'), context); vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/js/pages/HomePage.js'), 'utf8'), context); const channelList = Object.create(context.window.ChannelList.prototype); Object.assign(channelList, { container: { querySelectorAll: () => [], querySelector: () => null }, channels: [], renderedChannels: [], currentChannel: null, catalogVersion: 1, catalogScopeVersion: 1, hasMore: false, nextCursor: null, catalogPagePromise: null, isLoading: false, favoriteCatalog: [], hiddenItems: new Set(), showHidden: false }); channelList.setVisibleTestChannels = channels => { channelList.channels = channels; channelList.sortedGroups = ['Test']; channelList.groupedChannels = { Test: channels }; }; context.window.app = { channelList, navigateTo() {}, player: { currentChannel: null, async play(channel, streamUrl) { played.push({ channel, streamUrl }); } } }; return { context, channelList, played }; } test('live catalog pages bound the initial response and preserve filters across cursor pages', async t => { const fixture = await startApp(t); // Initialize the SQLite schema and search indexes before seeding deterministic rows. const initialized = await request(fixture.base, fixture.cookie); assert.equal(initialized.status, 200); seedCatalog(fixture.dir, fixture.userId); const first = await request(fixture.base, fixture.cookie); assert.equal(first.status, 200); assert.equal(first.body.items.length, 100); assert.equal(first.body.page.hasMore, true); assert.ok(!first.body.items.some(item => item.streamId === 'needle')); const all = await requestAllPages(fixture.base, fixture.cookie); const identities = all.map(item => `${item.sourceId}:${item.streamId}`); assert.equal(all.length, 1209); assert.equal(new Set(identities).size, all.length, 'cursor pages must not repeat channels'); const completeCatalogBytes = Buffer.byteLength(JSON.stringify(all)); assert.ok(first.bytes < completeCatalogBytes, `first page ${first.bytes} bytes should be smaller than complete catalog ${completeCatalogBytes} bytes`); assert.ok(all.some(item => item.streamId === 'needle')); assert.ok(!all.some(item => item.sourceId === 3), 'disabled sources are excluded'); const limited = await request(fixture.base, fixture.cookie, { limit: '37' }); assert.equal(limited.status, 200); assert.equal(limited.body.items.length, 37); assert.equal(limited.body.page.hasMore, true); const beyondFirstPage = await request(fixture.base, fixture.cookie, { q: 'Needle Far Away' }); assert.equal(beyondFirstPage.status, 200); assert.deepEqual(beyondFirstPage.body.items.map(item => item.streamId), ['needle']); const groupSearch = await requestAllPages(fixture.base, fixture.cookie, { q: 'Alpha Group' }); assert.equal(groupSearch.length, 600); assert.ok(groupSearch.every(item => item.groupTitle === 'Alpha Group')); const sourceFiltered = await request(fixture.base, fixture.cookie, { sourceId: '2' }); assert.equal(sourceFiltered.status, 200); assert.deepEqual(sourceFiltered.body.items.map(item => item.sourceId), [2, 2, 2, 2]); const categoryFiltered = await requestAllPages(fixture.base, fixture.cookie, { sourceId: '1', categoryId: 'beta' }); assert.equal(categoryFiltered.length, 598); assert.ok(categoryFiltered.every(item => item.groupId === 'beta')); const includeHidden = await requestAllPages(fixture.base, fixture.cookie, { sourceId: '1', includeHidden: 'true' }); assert.equal(includeHidden.length, 1204); assert.equal(includeHidden.filter(item => item.isHidden).length, 2); const hiddenGroup = await request(fixture.base, fixture.cookie, { sourceId: '1', categoryId: 'hidden' }); assert.equal(hiddenGroup.body.items.length, 0); const hiddenGroupIncluded = await request(fixture.base, fixture.cookie, { sourceId: '1', categoryId: 'hidden', includeHidden: 'true' }); assert.equal(hiddenGroupIncluded.body.items.length, 1); assert.equal(hiddenGroupIncluded.body.items[0].isHidden, true); const favoriteOnly = await request(fixture.base, fixture.cookie, { favoriteOnly: 'true' }); assert.equal(favoriteOnly.status, 200); assert.deepEqual(favoriteOnly.body.items.map(item => item.streamId), ['needle']); const otherUserFavorite = await request(fixture.base, fixture.cookie, { sourceId: '1', favoriteOnly: 'true', q: 'Channel 0000' }); assert.equal(otherUserFavorite.body.items.length, 0); }); test('keyset cursors retain group, channel, source, and item ordering at page boundaries', async t => { const fixture = await startApp(t); const initialized = await request(fixture.base, fixture.cookie); assert.equal(initialized.status, 200); seedCatalog(fixture.dir, fixture.userId); const ordered = await requestAllPages(fixture.base, fixture.cookie, { limit: '2', q: 'Tie' }); assert.deepEqual(ordered.map(item => `${item.groupTitle}:${item.sourceId}:${item.streamId}`), [ 'A Group:1:a-tie', 'A Group:1:z-tie', 'A Group:2:b-tie', 'A Group:4:c-tie', 'B Group:4:b-1', 'B Group:4:b-2' ]); }); test('case and accent group ties plus case-matched channels have deterministic order across every cursor page', async t => { const fixture = await startApp(t); const initialized = await request(fixture.base, fixture.cookie); assert.equal(initialized.status, 200); seedCatalog(fixture.dir, fixture.userId); seedIdentityAndOrderCases(fixture.dir); const ordered = await requestAllPages(fixture.base, fixture.cookie, { limit: '2', q: 'CaseVariant' }); assert.deepEqual(ordered.map(item => `${item.groupTitle}:${item.streamId}`), [ 'Eclair:upper-upper', 'Eclair:upper-lower', 'eclair:lower-upper', 'eclair:lower-lower', 'Éclair:accent-upper', 'Éclair:accent-lower' ]); const identities = ordered.map(item => `${item.sourceId}:${item.streamId}`); assert.equal(new Set(identities).size, ordered.length, 'all cursor pages must return each channel exactly once'); }); test('tab and Unicode whitespace sort like SQLite ASCII trim rules across every cursor page', async t => { const fixture = await startApp(t); const initialized = await request(fixture.base, fixture.cookie); assert.equal(initialized.status, 200); seedCatalog(fixture.dir, fixture.userId); seedIdentityAndOrderCases(fixture.dir); const ordered = await requestAllPages(fixture.base, fixture.cookie, { limit: '2', q: 'WhitespaceProbe' }); assert.deepEqual(ordered.map(item => `${item.groupTitle}|${item.name}`), [ ...['\tWhitespaceGroup\t', ' WhitespaceGroup ', '\u00a0WhitespaceGroup\u00a0'].flatMap(group => [ `${group}|\tWhitespaceProbe\t`, `${group}| WhitespaceProbe `, `${group}|\u00a0WhitespaceProbe\u00a0` ]) ]); const identities = ordered.map(item => `${item.sourceId}:${item.streamId}`); assert.equal(new Set(identities).size, 9, 'all whitespace variants must appear exactly once across cursor pages'); }); test('the same provider stream ID in two sources keeps distinct identities and selects the requested source in Chromium', { skip: process.env.NODECAST_BROWSER_TESTS === '1' ? false : 'Browser tests enabled in CI', timeout: 60000 }, async t => { const { chromium } = require('playwright'); const fixture = await startApp(t); const initialized = await request(fixture.base, fixture.cookie); assert.equal(initialized.status, 200); seedCatalog(fixture.dir, fixture.userId); seedIdentityAndOrderCases(fixture.dir); const duplicates = await requestAllPages(fixture.base, fixture.cookie, { q: 'Shared Collision Channel' }); assert.equal(duplicates.length, 2); assert.deepEqual(duplicates.map(channel => channel.streamId), ['same-provider-id', 'same-provider-id']); assert.deepEqual(duplicates.map(channel => channel.sourceId), [1, 4]); assert.equal(new Set(duplicates.map(channel => channel.id)).size, 2, 'the API identity must distinguish the same provider stream ID from different sources'); const browser = await chromium.launch({ headless: true, executablePath: process.env.NODECAST_BROWSER_EXECUTABLE || undefined }); t.after(() => browser.close()); const context = await browser.newContext(); const separator = fixture.cookie.indexOf('='); await context.addCookies([{ name: fixture.cookie.slice(0, separator), value: fixture.cookie.slice(separator + 1), url: fixture.base }]); const page = await context.newPage(); const pageErrors = []; page.on('pageerror', error => pageErrors.push(error.message)); await page.route('https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js', route => route.abort()); await page.route('https://fonts.**', route => route.abort()); await page.goto(`${fixture.base}/#live`); await page.waitForFunction(() => window.app?.channelList?.channels?.length === 200 && !window.app.channelList.isLoading); await page.locator('#channel-search').fill('Shared Collision Channel'); await page.waitForFunction(() => window.app?.channelList?.channels?.length === 2 && !window.app.channelList.isLoading); await page.evaluate(() => { window.__played = null; window.app.player.play = async (channel, streamUrl) => { window.__played = { id: channel.id, sourceId: channel.sourceId, streamUrl }; }; }); const target = duplicates.find(channel => channel.sourceId === 4); await page.evaluate(async targetChannel => { await window.app.channelList.selectChannel({ channelId: targetChannel.id, sourceId: targetChannel.sourceId }); window.app.channelList.expandAll(); }, target); const result = await page.evaluate(() => ({ played: window.__played, current: { id: window.app.channelList.currentChannel.id, sourceId: window.app.channelList.currentChannel.sourceId }, active: [...document.querySelectorAll('.channel-item.active')].map(item => ({ id: item.dataset.channelId, sourceId: Number(item.dataset.sourceId) })) })); assert.deepEqual(result.played, { id: target.id, sourceId: 4, streamUrl: 'http://streams.test/shared-tertiary' }); assert.deepEqual(result.current, { id: target.id, sourceId: 4 }); assert.deepEqual(result.active, [{ id: target.id, sourceId: 4 }]); await page.locator('#channel-search').fill('CaseVariant'); await page.waitForFunction(() => window.app?.channelList?.channels?.length === 6 && !window.app.channelList.isLoading); await page.evaluate(() => window.app.channelList.expandAll()); const orderedDisplay = await page.evaluate(() => ({ visible: [...document.querySelectorAll('.channel-item')].map(item => item.dataset.channelId), navigation: window.app.channelList.getVisibleChannels().map(channel => channel.id) })); const expectedOrder = (await requestAllPages(fixture.base, fixture.cookie, { limit: '2', q: 'CaseVariant' })).map(channel => channel.id); assert.deepEqual(orderedDisplay.visible, expectedOrder); assert.deepEqual(orderedDisplay.navigation, expectedOrder, 'zap navigation order must match the channels displayed across cursor pages'); await page.evaluate(async () => { const list = window.app.channelList; list.currentChannel = list.getVisibleChannels()[0]; await list.queueZap(1); }); const zapped = await page.evaluate(() => window.__played); assert.equal(zapped.id, expectedOrder[1]); assert.equal(zapped.sourceId, 1); await page.locator('#channel-search').fill('WhitespaceProbe'); await page.waitForFunction(() => window.app?.channelList?.channels?.length === 9 && !window.app.channelList.isLoading); await page.evaluate(() => window.app.channelList.expandAll()); const whitespaceDisplay = await page.evaluate(() => ({ visible: [...document.querySelectorAll('.channel-item')].map(item => item.dataset.channelId), navigation: window.app.channelList.getVisibleChannels().map(channel => channel.id) })); const whitespaceOrder = (await requestAllPages(fixture.base, fixture.cookie, { limit: '2', q: 'WhitespaceProbe' })).map(channel => channel.id); assert.deepEqual(whitespaceDisplay.visible, whitespaceOrder); assert.deepEqual(whitespaceDisplay.navigation, whitespaceOrder, 'display and zap order must retain tab and Unicode whitespace distinctions from ASCII spaces'); await page.evaluate(async () => { const list = window.app.channelList; list.currentChannel = list.getVisibleChannels()[0]; await list.queueZap(1); }); assert.equal((await page.evaluate(() => window.__played)).id, whitespaceOrder[1]); assert.deepEqual(pageErrors, []); await context.close(); }); test('zapping forward from the last channel in page one fetches and plays page two first', async () => { const { channelList, played } = clientPageFixture(); const pages = [ [0, 1].map(id => ({ id: `ch${id}`, sourceId: 1, sourceType: 'm3u', url: `http://streams.test/ch${id}` })), [2, 3].map(id => ({ id: `ch${id}`, sourceId: 1, sourceType: 'm3u', url: `http://streams.test/ch${id}` })) ]; channelList.setVisibleTestChannels([...pages[0]]); channelList.currentChannel = pages[0][1]; channelList.contextPageLoads = []; channelList.hasMore = true; channelList.nextCursor = 'page-2'; channelList.loadCatalogPage = async append => { assert.equal(append, true); channelList.contextPageLoads.push(channelList.nextCursor); channelList.channels.push(...pages[1]); channelList.hasMore = false; channelList.nextCursor = null; return true; }; await channelList.queueZap(1); assert.deepEqual(channelList.contextPageLoads, ['page-2']); assert.equal(played.length, 1); assert.equal(played[0].channel.id, 'ch2'); assert.equal(channelList.currentChannel.id, 'ch2'); }); test('zapping previous from page one loads through the true end before wrapping', async () => { const { channelList, played } = clientPageFixture(); const pages = [ [0, 1], [2, 3], [4] ].map(page => page.map(id => ({ id: `ch${id}`, sourceId: 1, sourceType: 'm3u', url: `http://streams.test/ch${id}` }))); channelList.setVisibleTestChannels([...pages[0]]); channelList.currentChannel = pages[0][0]; channelList.contextPageLoads = []; channelList.hasMore = true; channelList.nextCursor = 'page-2'; channelList.loadCatalogPage = async append => { assert.equal(append, true); channelList.contextPageLoads.push(channelList.nextCursor); const nextPage = channelList.contextPageLoads.length; channelList.channels.push(...pages[nextPage]); channelList.hasMore = nextPage < pages.length - 1; channelList.nextCursor = channelList.hasMore ? `page-${nextPage + 2}` : null; return true; }; await channelList.queueZap(-1); assert.deepEqual(channelList.contextPageLoads, ['page-2', 'page-3']); assert.equal(played.length, 1); assert.equal(played[0].channel.id, 'ch4'); assert.equal(channelList.currentChannel.id, 'ch4'); }); test('failed cross-page fetch or playback keeps the current channel selected', async () => { const { channelList, played } = clientPageFixture(); const current = { id: 'last-on-page', sourceId: 1, sourceType: 'm3u', url: 'http://streams.test/current' }; channelList.setVisibleTestChannels([current]); channelList.currentChannel = current; channelList.hasMore = true; channelList.nextCursor = 'page-2'; channelList.loadCatalogPage = async () => false; await channelList.queueZap(1); assert.equal(channelList.currentChannel, current); assert.deepEqual(played, []); const playbackFixture = clientPageFixture(); const playbackCurrent = { ...current, id: 'before-playback-failure' }; const next = { id: 'next', sourceId: 1, sourceType: 'm3u', url: 'http://streams.test/next' }; playbackFixture.channelList.setVisibleTestChannels([playbackCurrent]); playbackFixture.channelList.currentChannel = playbackCurrent; playbackFixture.channelList.hasMore = true; playbackFixture.channelList.nextCursor = 'page-2'; playbackFixture.channelList.loadCatalogPage = async () => { playbackFixture.channelList.channels.push(next); playbackFixture.channelList.hasMore = false; playbackFixture.channelList.nextCursor = null; return true; }; playbackFixture.channelList.selectChannel = async () => { playbackFixture.channelList.currentChannel = next; throw new Error('simulated playback failure'); }; await playbackFixture.channelList.queueZap(1); assert.equal(playbackFixture.channelList.currentChannel, playbackCurrent); assert.deepEqual(playbackFixture.played, []); }); test('Home favorite can launch a channel absent from the loaded Live page', () => { const { context, channelList, played } = clientPageFixture(); const favorite = { id: 'home-only', sourceId: 2, sourceType: 'm3u', url: 'http://streams.test/home-only' }; channelList.channels = [{ id: 'live-page-item', sourceId: 1, sourceType: 'm3u', url: 'http://streams.test/live' }]; channelList.favoriteCatalog = [favorite]; let navigatedTo = null; context.window.app.navigateTo = page => { navigatedTo = page; }; context.window.HomePage.prototype.playChannel.call({ app: context.window.app }, favorite.id, favorite.sourceId); assert.equal(navigatedTo, 'live'); assert.equal(played.length, 1); assert.equal(played[0].channel.id, favorite.id); assert.equal(played[0].channel.sourceId, favorite.sourceId); assert.equal(played[0].streamUrl, favorite.url); }); test('Live search debounce, Enter, Search button, Load more, and channel selection work in Chromium', { skip: process.env.NODECAST_BROWSER_TESTS === '1' ? false : 'Browser tests enabled in CI', timeout: 60000 }, async t => { const { chromium } = require('playwright'); const fixture = await startApp(t); const initialized = await request(fixture.base, fixture.cookie); assert.equal(initialized.status, 200); seedCatalog(fixture.dir, fixture.userId); const browser = await chromium.launch({ headless: true, executablePath: process.env.NODECAST_BROWSER_EXECUTABLE || undefined }); t.after(() => browser.close()); const context = await browser.newContext(); const separator = fixture.cookie.indexOf('='); await context.addCookies([{ name: fixture.cookie.slice(0, separator), value: fixture.cookie.slice(separator + 1), url: fixture.base }]); const page = await context.newPage(); const catalogRequests = []; const pageErrors = []; page.on('request', req => { const url = new URL(req.url()); if (url.pathname === '/api/channels/live-catalog') catalogRequests.push(url.searchParams); }); page.on('pageerror', error => pageErrors.push(error.message)); await page.route('https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js', route => route.abort()); await page.route('https://fonts.**', route => route.abort()); await page.goto(`${fixture.base}/#live`); await page.waitForFunction(() => window.app?.channelList?.channels?.length === 200 && !window.app.channelList.isLoading); assert.equal(catalogRequests[0].get('limit'), '200'); assert.equal(await page.locator('#channel-search-submit').isVisible(), true); assert.equal(await page.locator('.live-catalog-more').isVisible(), true); const initialRequestCount = catalogRequests.length; await page.locator('#channel-search').fill('Needle Far Away'); await page.waitForTimeout(100); assert.equal(catalogRequests.length, initialRequestCount, 'debounce should defer the request'); await page.waitForFunction(() => window.app?.channelList?.channels?.some(channel => channel.streamId === 'needle')); assert.equal(catalogRequests.at(-1).get('q'), 'Needle Far Away'); assert.equal(await page.evaluate(() => window.app.channelList.channels.length), 1); await page.locator('#channel-search').fill('Alpha Group'); await page.waitForTimeout(50); const enterCount = catalogRequests.length; const enterRequest = page.waitForRequest(req => { const url = new URL(req.url()); return url.pathname === '/api/channels/live-catalog' && url.searchParams.get('q') === 'Alpha Group'; }); await page.locator('#channel-search').press('Enter'); await enterRequest; await page.waitForFunction(() => window.app?.channelList?.channels?.length > 0 && window.app.channelList.channels.every(channel => channel.groupTitle === 'Alpha Group')); assert.ok(catalogRequests.length > enterCount); assert.equal(catalogRequests.at(-1).get('q'), 'Alpha Group'); await page.locator('#channel-search').fill('Other Group'); const buttonRequest = page.waitForRequest(req => { const url = new URL(req.url()); return url.pathname === '/api/channels/live-catalog' && url.searchParams.get('q') === 'Other Group'; }); await page.locator('#channel-search-submit').click(); await buttonRequest; await page.waitForFunction(() => window.app?.channelList?.channels?.length === 3 && window.app.channelList.channels.every(channel => channel.groupTitle === 'Other Group')); assert.equal(catalogRequests.at(-1).get('q'), 'Other Group'); await page.locator('#channel-search').fill(''); await page.waitForFunction(() => window.app?.channelList?.channels?.length === 200 && !window.app.channelList.isLoading); const beforeMore = catalogRequests.length; await page.locator('.live-catalog-more').click(); await page.waitForFunction(() => window.app?.channelList?.channels?.length === 400 && !window.app.channelList.isLoading); assert.ok(catalogRequests.length > beforeMore); assert.ok(catalogRequests.at(-1).get('cursor')); await page.evaluate(() => { window.__selectedChannel = null; window.app.player.play = async (channel, streamUrl) => { window.__selectedChannel = { id: channel.id, sourceId: channel.sourceId, streamUrl }; }; window.app.channelList.expandAll(); }); const firstItem = page.locator('.channel-item').first(); await firstItem.waitFor({ state: 'visible' }); await firstItem.click(); await page.waitForFunction(() => window.__selectedChannel !== null); const selected = await page.evaluate(() => window.__selectedChannel); assert.ok(selected.id); assert.ok(selected.sourceId); assert.match(selected.streamUrl, /^http:\/\/streams\.test\//); assert.deepEqual(pageErrors, []); await context.close(); }); test('Guide includes visible channels in hidden groups and excludes individually hidden channels', { skip: process.env.NODECAST_BROWSER_TESTS === '1' ? false : 'Browser tests enabled in CI', timeout: 60000 }, async t => { const { chromium } = require('playwright'); const fixture = await startApp(t); const initialized = await request(fixture.base, fixture.cookie); assert.equal(initialized.status, 200); seedCatalog(fixture.dir, fixture.userId); const browser = await chromium.launch({ headless: true, executablePath: process.env.NODECAST_BROWSER_EXECUTABLE || undefined }); t.after(() => browser.close()); const context = await browser.newContext(); const separator = fixture.cookie.indexOf('='); await context.addCookies([{ name: fixture.cookie.slice(0, separator), value: fixture.cookie.slice(separator + 1), url: fixture.base }]); const page = await context.newPage(); const catalogRequests = []; const pageErrors = []; page.on('request', req => { const url = new URL(req.url()); if (url.pathname === '/api/channels/live-catalog') catalogRequests.push(url.searchParams); }); page.on('pageerror', error => pageErrors.push(error.message)); await page.route('**/api/proxy/epg/**', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ channels: [], programmes: [] }) })); await page.route('https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js', route => route.abort()); await page.route('https://fonts.**', route => route.abort()); await page.goto(`${fixture.base}/#guide`); await page.waitForFunction(() => window.app?.channelList?.guideCatalog?.length >= 1200 && window.app?.epgGuide?.allMatchedChannels?.length > 0); assert.ok(catalogRequests.some(params => params.get('includeHidden') === 'true')); const guideState = await page.evaluate(() => { const guide = window.app.epgGuide; const ids = guide.allMatchedChannels.map(match => match.sourceChannel.streamId || match.sourceChannel.id); return { hiddenGroupChannelIncluded: ids.includes('hidden-group'), individuallyHiddenChannelIncluded: ids.includes('hidden-item'), groupOptions: [...document.querySelector('#epg-group-select').options].map(option => option.value) }; }); assert.equal(guideState.hiddenGroupChannelIncluded, true); assert.equal(guideState.individuallyHiddenChannelIncluded, false); assert.ok(guideState.groupOptions.includes('Hidden Group')); await page.locator('#epg-group-select').selectOption({ label: 'Hidden Group' }); await page.waitForFunction(() => window.app.epgGuide.filteredChannels.length === 1 && window.app.epgGuide.filteredChannels[0].sourceChannel.streamId === 'hidden-group'); await page.locator('#epg-search').fill('Hidden Item Channel'); await page.waitForFunction(() => window.app.epgGuide.filteredChannels.length === 0); assert.deepEqual(pageErrors, []); await context.close(); });