mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
import { Page } from '@playwright/test';
|
|
import {
|
|
buildM3uContent,
|
|
channelItemByTitle,
|
|
closeElectronApp,
|
|
createMutableTextServer,
|
|
expect,
|
|
importM3uPlaylistFromUrl,
|
|
launchElectronApp,
|
|
openWorkspaceSection,
|
|
openSettings,
|
|
test,
|
|
} from './electron-test-fixtures';
|
|
|
|
const fallbackLogoSvg = Buffer.from(
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><rect width="96" height="96" rx="20" fill="#16436b"/><text x="48" y="58" text-anchor="middle" font-size="28" font-family="Arial" fill="#f5f7fb">GN</text></svg>`
|
|
).toString('base64');
|
|
const fallbackLogoDataUrl = `data:image/svg+xml;base64,${fallbackLogoSvg}`;
|
|
const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<tv>
|
|
<channel id="guide-news">
|
|
<display-name>Guide News</display-name>
|
|
</channel>
|
|
<programme start="20260328070000 +0000" stop="20260328080000 +0000" channel="guide-news">
|
|
<title>Guide Bulletin</title>
|
|
<desc>EPG refresh smoke test.</desc>
|
|
</programme>
|
|
</tv>
|
|
`;
|
|
|
|
function createCurrentXmltvFixture(
|
|
channelId: string,
|
|
channelName: string,
|
|
programTitle: string
|
|
): string {
|
|
const start = new Date(Date.now() - 15 * 60 * 1000);
|
|
const stop = new Date(Date.now() + 45 * 60 * 1000);
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<tv>
|
|
<channel id="${channelId}">
|
|
<display-name>${channelName}</display-name>
|
|
</channel>
|
|
<programme start="${formatXmltvDate(start)} +0000" stop="${formatXmltvDate(stop)} +0000" channel="${channelId}">
|
|
<title>${programTitle}</title>
|
|
<desc>M3U-declared EPG source smoke test.</desc>
|
|
</programme>
|
|
</tv>
|
|
`;
|
|
}
|
|
|
|
function formatXmltvDate(date: Date): string {
|
|
const pad = (value: number) => String(value).padStart(2, '0');
|
|
|
|
return [
|
|
date.getUTCFullYear(),
|
|
pad(date.getUTCMonth() + 1),
|
|
pad(date.getUTCDate()),
|
|
pad(date.getUTCHours()),
|
|
pad(date.getUTCMinutes()),
|
|
pad(date.getUTCSeconds()),
|
|
].join('');
|
|
}
|
|
|
|
test.describe('Electron EPG', () => {
|
|
test('@epg @electron adds an EPG source, fetches guide data, removes the source row, and clears stored EPG data', async ({
|
|
dataDir,
|
|
}) => {
|
|
const epgServer = await createMutableTextServer(epgFixtureXml, {
|
|
contentType: 'application/xml; charset=utf-8',
|
|
resourcePath: '/guide.xml',
|
|
});
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await openSettings(app.mainWindow);
|
|
await app.mainWindow
|
|
.getByRole('button', { name: 'Add EPG source' })
|
|
.click();
|
|
await app.mainWindow
|
|
.locator('.epg-source-row input')
|
|
.first()
|
|
.fill(epgServer.resourceUrl);
|
|
|
|
await app.mainWindow
|
|
.locator('.epg-source-row button')
|
|
.first()
|
|
.click();
|
|
await expect(
|
|
app.mainWindow.locator('.epg-progress-panel')
|
|
).toBeVisible();
|
|
await expect
|
|
.poll(() => getEpgChannelCount(app.mainWindow), {
|
|
timeout: 30000,
|
|
})
|
|
.toBeGreaterThan(0);
|
|
await expect(
|
|
app.mainWindow
|
|
.locator('.epg-progress-panel .stat-badge')
|
|
.first()
|
|
).toBeVisible();
|
|
|
|
await app.mainWindow
|
|
.locator('.epg-source-row button')
|
|
.nth(1)
|
|
.click();
|
|
await expect(app.mainWindow.locator('.epg-source-row')).toHaveCount(
|
|
0
|
|
);
|
|
|
|
await app.mainWindow
|
|
.getByRole('button', { name: 'Clear EPG data' })
|
|
.click();
|
|
const dialog = app.mainWindow.locator('mat-dialog-container');
|
|
await expect(dialog).toBeVisible();
|
|
await dialog
|
|
.getByRole('button', { name: 'Yes', exact: true })
|
|
.click();
|
|
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
|
state: 'detached',
|
|
});
|
|
|
|
await expect
|
|
.poll(() => getEpgChannelCount(app.mainWindow), {
|
|
timeout: 20000,
|
|
})
|
|
.toBe(0);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
await epgServer.close();
|
|
}
|
|
});
|
|
|
|
test('@epg @electron imports and renders an EPG source declared by an M3U playlist header', async ({
|
|
dataDir,
|
|
}) => {
|
|
const epgServer = await createMutableTextServer(
|
|
createCurrentXmltvFixture(
|
|
'playlist-guide-news',
|
|
'Playlist Guide News',
|
|
'Playlist Scoped Bulletin'
|
|
),
|
|
{
|
|
contentType: 'application/xml; charset=utf-8',
|
|
resourcePath: '/guides/ua/playlist-guide.xml',
|
|
}
|
|
);
|
|
const detectedEpgUrls = [
|
|
`${epgServer.origin}/guides/us/ignored.xml`,
|
|
`${epgServer.origin}/guides/de/ignored.xml`,
|
|
epgServer.resourceUrl,
|
|
`${epgServer.origin}/guides/fr/ignored.xml`,
|
|
`${epgServer.origin}/guides/uk/ignored.xml`,
|
|
`${epgServer.origin}/guides/es/ignored.xml`,
|
|
];
|
|
const playlistContent = buildM3uContent([
|
|
{
|
|
name: 'Playlist Guide News',
|
|
tvgCountry: 'UA',
|
|
tvgId: 'playlist-guide-news',
|
|
url: 'https://example.com/live/playlist-guide-news.m3u8',
|
|
},
|
|
]).replace(
|
|
'#EXTM3U',
|
|
`#EXTM3U x-tvg-url="${detectedEpgUrls.join(',')}"`
|
|
);
|
|
const playlistServer = await createMutableTextServer(playlistContent, {
|
|
contentType: 'application/x-mpegurl; charset=utf-8',
|
|
resourcePath: '/playlist-with-epg.m3u',
|
|
});
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await importM3uPlaylistFromUrl(
|
|
app.mainWindow,
|
|
playlistServer.resourceUrl
|
|
);
|
|
|
|
await expect(
|
|
app.mainWindow.locator('.epg-progress-panel')
|
|
).toBeVisible();
|
|
await expect(
|
|
app.mainWindow.locator('.epg-progress-panel .import-item')
|
|
).toHaveCount(1);
|
|
await expect(
|
|
app.mainWindow.locator(
|
|
'.epg-progress-panel .import-item.status-complete'
|
|
)
|
|
).toHaveCount(1, { timeout: 30000 });
|
|
await expect
|
|
.poll(() => getEpgChannelCount(app.mainWindow), {
|
|
timeout: 30000,
|
|
})
|
|
.toBeGreaterThan(0);
|
|
|
|
await openWorkspaceSection(app.mainWindow, 'All channels');
|
|
|
|
const channelItem = channelItemByTitle(
|
|
app.mainWindow,
|
|
'Playlist Guide News'
|
|
);
|
|
await expect(channelItem).toBeVisible();
|
|
await expect(channelItem.locator('.epg-title')).toContainText(
|
|
'Playlist Scoped Bulletin',
|
|
{ timeout: 30000 }
|
|
);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
await playlistServer.close();
|
|
await epgServer.close();
|
|
}
|
|
});
|
|
|
|
test('@epg @electron uses the XMLTV channel icon as a fallback when the playlist has no tvg-logo', async ({
|
|
dataDir,
|
|
}) => {
|
|
const playlistServer = await createMutableTextServer(
|
|
buildM3uContent([
|
|
{
|
|
name: 'Guide News Live',
|
|
tvgId: 'guide-news',
|
|
url: 'https://example.com/live/guide-news.m3u8',
|
|
},
|
|
]),
|
|
{
|
|
contentType: 'application/x-mpegurl; charset=utf-8',
|
|
resourcePath: '/playlist.m3u',
|
|
}
|
|
);
|
|
const epgServer = await createMutableTextServer(
|
|
`<?xml version="1.0" encoding="UTF-8"?>
|
|
<tv>
|
|
<channel id="guide-news">
|
|
<display-name>Guide News Live</display-name>
|
|
<icon src="${fallbackLogoDataUrl}"/>
|
|
</channel>
|
|
<programme start="20260328070000 +0000" stop="20260328080000 +0000" channel="guide-news">
|
|
<title>Guide Bulletin</title>
|
|
<desc>EPG logo fallback test.</desc>
|
|
</programme>
|
|
</tv>
|
|
`,
|
|
{
|
|
contentType: 'application/xml; charset=utf-8',
|
|
resourcePath: '/guide.xml',
|
|
}
|
|
);
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await importM3uPlaylistFromUrl(
|
|
app.mainWindow,
|
|
playlistServer.resourceUrl
|
|
);
|
|
|
|
await openSettings(app.mainWindow);
|
|
await app.mainWindow
|
|
.getByRole('button', { name: 'Add EPG source' })
|
|
.click();
|
|
await app.mainWindow
|
|
.locator('.epg-source-row input')
|
|
.first()
|
|
.fill(epgServer.resourceUrl);
|
|
await app.mainWindow
|
|
.locator('.epg-source-row button')
|
|
.first()
|
|
.click();
|
|
|
|
await expect
|
|
.poll(() => getEpgChannelCount(app.mainWindow), {
|
|
timeout: 30000,
|
|
})
|
|
.toBeGreaterThan(0);
|
|
|
|
await openWorkspaceSection(app.mainWindow, 'All channels');
|
|
|
|
const channelItem = channelItemByTitle(
|
|
app.mainWindow,
|
|
'Guide News Live'
|
|
);
|
|
await expect(channelItem).toBeVisible();
|
|
await expect(channelItem.locator('.channel-logo')).toHaveAttribute(
|
|
'src',
|
|
fallbackLogoDataUrl
|
|
);
|
|
await expect(
|
|
channelItem.locator('.channel-logo-fallback')
|
|
).toHaveCount(0);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
await playlistServer.close();
|
|
await epgServer.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
async function getEpgChannelCount(page: Page): Promise<number> {
|
|
return page.evaluate(async () => {
|
|
const channels = await window.electron?.getEpgChannelsByRange?.(0, 20);
|
|
return Array.isArray(channels) ? channels.length : 0;
|
|
});
|
|
}
|