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; } async function assertFullCatalogMatchesPages(base, cookie, query = {}) { const paged = await requestAllPages(base, cookie, query); const response = await request(base, cookie, { ...query, full: 'true' }); assert.equal(response.status, 200); assert.deepEqual(response.body.items, paged, `full catalogue ordering and rows should equal concatenated pages for ${JSON.stringify(query)}`); assert.deepEqual(response.body.page, { nextCursor: null, hasMore: false }); return response.body.items; } test('application startup creates the composite category lookup index', async t => { const app = await startApp(t); const response = await request(app.base, app.cookie); assert.equal(response.status, 200); const db = new Database(path.join(app.dir, 'content.db'), { fileMustExist: true }); t.after(() => db.close()); const index = db.prepare(`SELECT sql FROM sqlite_master WHERE type='index' AND name='idx_categories_source_type_category'`).get(); assert.ok(index, 'startup schema should create idx_categories_source_type_category'); assert.match(index.sql, /\(source_id,\s*type,\s*category_id\)/i); }); 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 seedManyFavorites(dir, userId, count) { const db = new Database(path.join(dir, 'content.db')); const insert = db.prepare(`INSERT OR IGNORE INTO favorites (user_id, source_id, item_id, item_type) VALUES (?, 1, ?, 'channel')`); db.transaction(() => { for (let i = 0; i < count; i++) insert.run(userId, `primary-${String(i).padStart(4, '0')}`); })(); 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 complete = await assertFullCatalogMatchesPages(fixture.base, fixture.cookie); assert.equal(complete.length, all.length); 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']); assert.deepEqual(await assertFullCatalogMatchesPages(fixture.base, fixture.cookie, { q: 'Needle Far Away' }), beyondFirstPage.body.items); 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')); assert.deepEqual(await assertFullCatalogMatchesPages(fixture.base, fixture.cookie, { q: 'Alpha Group' }), groupSearch); 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]); assert.deepEqual(await assertFullCatalogMatchesPages(fixture.base, fixture.cookie, { sourceId: '2' }), await requestAllPages(fixture.base, fixture.cookie, { sourceId: '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')); assert.deepEqual(await assertFullCatalogMatchesPages(fixture.base, fixture.cookie, { sourceId: '1', categoryId: 'beta' }), categoryFiltered); 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); assert.deepEqual(await assertFullCatalogMatchesPages(fixture.base, fixture.cookie, { sourceId: '1', includeHidden: 'true' }), includeHidden); 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']); assert.deepEqual(await assertFullCatalogMatchesPages(fixture.base, fixture.cookie, { favoriteOnly: 'true' }), favoriteOnly.body.items); 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 >= 1209 && !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 at either end of the full Live list wraps without requesting another page', async () => { const { channelList, played } = clientPageFixture(); const channels = [0, 1, 2].map(id => ({ id: `ch${id}`, sourceId: 1, sourceType: 'm3u', url: `http://streams.test/ch${id}` })); channelList.setVisibleTestChannels(channels); channelList.currentChannel = channels[2]; channelList.contextPageLoads = []; channelList.loadCatalogPage = async () => { channelList.contextPageLoads.push('unexpected'); }; await channelList.queueZap(1); assert.deepEqual(channelList.contextPageLoads, []); assert.equal(played.length, 1); assert.equal(played[0].channel.id, 'ch0'); assert.equal(channelList.currentChannel.id, 'ch0'); channelList.currentChannel = channels[0]; await channelList.queueZap(-1); assert.deepEqual(channelList.contextPageLoads, []); assert.equal(played.length, 2); assert.equal(played[1].channel.id, 'ch2'); assert.equal(channelList.currentChannel.id, 'ch2'); }); test('failed zap playback keeps the current channel selected without fetching a page', async () => { const { channelList, played } = clientPageFixture(); const current = { id: 'current', sourceId: 1, sourceType: 'm3u', url: 'http://streams.test/current' }; const next = { id: 'next', sourceId: 1, sourceType: 'm3u', url: 'http://streams.test/next' }; channelList.setVisibleTestChannels([current, next]); channelList.currentChannel = current; channelList.contextPageLoads = []; channelList.loadCatalogPage = async () => { channelList.contextPageLoads.push('unexpected'); }; channelList.selectChannel = async () => { throw new Error('simulated playback failure'); }; await channelList.queueZap(1); assert.deepEqual(channelList.contextPageLoads, []); assert.equal(channelList.currentChannel, current); assert.deepEqual(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 full catalogue search, zapping list, and channel selection work without Load more 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 === 1209 && !window.app.channelList.isLoading); assert.equal(catalogRequests[0].get('full'), 'true'); assert.equal(catalogRequests[0].get('limit'), null); assert.equal(catalogRequests[0].get('cursor'), null); assert.equal(await page.locator('#channel-search-submit').isVisible(), true); assert.equal(await page.locator('.live-catalog-more').count(), 0, 'Live should not expose a Load more button when it loads the full catalogue'); const initialRequestCount = catalogRequests.length; const needleRequest = page.waitForRequest(req => { const url = new URL(req.url()); return url.pathname === '/api/channels/live-catalog' && url.searchParams.get('q') === 'Needle Far Away'; }); await page.locator('#channel-search').fill('Needle Far Away'); await page.waitForTimeout(100); assert.equal(catalogRequests.length, initialRequestCount, 'debounce should defer the request'); await needleRequest; await page.waitForFunction(() => window.app?.channelList?.channels?.length === 1 && window.app.channelList.channels[0].streamId === 'needle' && !window.app.channelList.isLoading); assert.equal(catalogRequests.at(-1).get('q'), 'Needle Far Away'); assert.equal(catalogRequests.at(-1).get('full'), 'true'); 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 === 600 && window.app.channelList.channels.every(channel => channel.groupTitle === 'Alpha Group')); assert.ok(catalogRequests.length > enterCount); assert.equal(catalogRequests.at(-1).get('q'), 'Alpha Group'); assert.equal(catalogRequests.at(-1).get('full'), 'true'); assert.equal(catalogRequests.at(-1).get('cursor'), null); assert.equal(await page.locator('.live-catalog-more').count(), 0); 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'); assert.equal(catalogRequests.at(-1).get('full'), 'true'); await page.locator('#channel-search').fill(''); await page.waitForFunction(() => window.app?.channelList?.channels?.length === 1209 && !window.app.channelList.isLoading); assert.equal(catalogRequests.at(-1).get('full'), 'true'); assert.equal(catalogRequests.at(-1).get('cursor'), null); assert.equal(await page.locator('.live-catalog-more').count(), 0); 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('Home favorites remain paginated with a cursor-backed Load more action 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); seedManyFavorites(fixture.dir, fixture.userId, 205); 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 favoriteRequests = []; page.on('request', req => { const url = new URL(req.url()); if (url.pathname === '/api/channels/live-catalog' && url.searchParams.get('favoriteOnly') === 'true') favoriteRequests.push(url.searchParams); }); 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}/#home`); await page.waitForFunction(() => window.app?.channelList?.favoriteCatalog?.length === 200); assert.equal(favoriteRequests[0].get('limit'), '200'); assert.equal(favoriteRequests[0].get('cursor'), null); assert.equal(favoriteRequests[0].get('full'), null); const loadMore = page.locator('#favorite-channels-list .channel-tile-action'); assert.equal(await loadMore.count(), 1); const beforeMore = favoriteRequests.length; await loadMore.click(); await page.waitForFunction(() => window.app?.channelList?.favoriteCatalog?.length === 206); assert.ok(favoriteRequests.length > beforeMore); assert.equal(favoriteRequests.at(-1).get('limit'), '200'); assert.ok(favoriteRequests.at(-1).get('cursor')); assert.equal(favoriteRequests.at(-1).get('full'), null); 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); const guideRequests = catalogRequests.filter(params => params.get('includeHidden') === 'true'); assert.ok(guideRequests.length >= 3, 'Guide should retrieve every fixture row through multiple pages'); assert.equal(guideRequests[0].get('limit'), '500'); assert.equal(guideRequests[0].get('cursor'), null); assert.equal(guideRequests[0].get('full'), null); assert.ok(guideRequests.slice(1).every(params => params.get('limit') === '500' && params.get('cursor') && params.get('full') === null), 'Guide continuation requests should use cursors and remain paginated'); 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(); });