Files
NodecastV2/test/live-catalog-initial-load.test.js
Blomios d64a902155 feat(live): remove pagination from Live catalog (#51)
Keep server-side search and cursor pagination for Home and Guide while allowing Live to load the complete filtered catalogue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-22 23:51:02 +02:00

742 lines
40 KiB
JavaScript

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();
});