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>
742 lines
40 KiB
JavaScript
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();
|
|
});
|