Files
iptvnator/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts
T

505 lines
18 KiB
TypeScript

import { Page } from '@playwright/test';
import {
channelItemByTitle,
addStalkerPortal,
addXtreamPortal,
closeElectronApp,
defaultXtreamPassword,
defaultXtreamUsername,
expect,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
openPlaylistFavorites,
openPlaylistRecent,
openSettings,
openSourceEditor,
openSources,
openWorkspaceSection,
playlistSwitcherTitle,
resetMockServers,
restartElectronApp,
saveSettings,
saveSourceDialog,
sourceRowByTitle,
test,
updateSourceDialog,
waitForDbOperationEvent,
waitForM3uCatalog,
waitForStalkerCatalog,
waitForXtreamCatalog,
writeTemporaryM3uFile,
xtreamMockServer,
} from './electron-test-fixtures';
const xtreamStressUsername = 'stress';
const xtreamStressPassword = 'stress';
const dbWorkerBatchDelayMs = '20';
const localAFileName = 'switcher-local-a.m3u';
const localADisplayName = 'switcher-local-a';
const localBFileName = 'switcher-local-b.m3u';
const localBDisplayName = 'switcher-local-b';
const localAChannelName = 'Switcher Favorite A';
const localBChannelName = 'Switcher Favorite B';
const offlineLocalFileName = 'offline-local-source.m3u';
const offlineLocalDisplayName = 'offline-local-source';
const xtreamAName = 'Switcher Xtream A';
const xtreamBName = 'Switcher Xtream B';
const stalkerAName = 'Switcher Stalker A';
const stalkerBName = 'Switcher Stalker B';
const offlineXtreamName = 'Offline Cached Xtream';
const importingXtreamName = 'Importing Xtream Portal';
test.describe('Electron Playlist Switcher', () => {
test.describe.configure({ mode: 'serial' });
test.setTimeout(120000);
test('preserves same-type sections and cross-type mappings when switching from the workspace header', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream', 'stalker']);
const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [
{
groupTitle: 'News',
name: 'Switcher Local A',
url: 'https://streams.example.test/switcher-local-a.m3u8',
},
]);
const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [
{
groupTitle: 'Sports',
name: 'Switcher Local B',
url: 'https://streams.example.test/switcher-local-b.m3u8',
},
]);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, localAPath);
await importM3uPlaylistFromNativeDialog(app, localBPath);
await addXtreamPortal(app.mainWindow, {
name: xtreamAName,
});
await addXtreamPortal(app.mainWindow, {
name: xtreamBName,
});
await addStalkerPortal(app.mainWindow, {
name: stalkerAName,
});
await addStalkerPortal(app.mainWindow, {
name: stalkerBName,
});
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, localADisplayName)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await openPlaylistRecent(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
await switchPlaylistFromHeader(app.mainWindow, localBDisplayName);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
localBDisplayName
);
await switchPlaylistFromHeader(app.mainWindow, xtreamAName);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
xtreamAName
);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await waitForXtreamCatalog(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/live$/
);
await switchPlaylistFromHeader(app.mainWindow, xtreamBName);
await waitForXtreamCatalog(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/live$/
);
await openWorkspaceSection(app.mainWindow, 'Recently added');
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/recently-added$/
);
await switchPlaylistFromHeader(app.mainWindow, stalkerAName);
await expectPathname(
app.mainWindow,
/\/workspace\/stalker\/[^/]+\/recent$/
);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await waitForStalkerCatalog(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/stalker\/[^/]+\/itv$/
);
await switchPlaylistFromHeader(app.mainWindow, stalkerBName);
await waitForStalkerCatalog(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/stalker\/[^/]+\/itv$/
);
await switchPlaylistFromHeader(app.mainWindow, xtreamAName);
await waitForXtreamCatalog(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/live$/
);
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
await waitForM3uCatalog(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/playlists\/[^/]+\/all$/
);
await openPlaylistFavorites(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await switchPlaylistFromHeader(app.mainWindow, stalkerBName);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
stalkerBName
);
} finally {
await closeElectronApp(app);
}
});
test('refreshes M3U unified collections immediately when switching same-type playlists from the header', async ({
dataDir,
}) => {
const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [
{
groupTitle: 'News',
name: localAChannelName,
url: 'https://streams.example.test/switcher-favorite-a.m3u8',
},
]);
const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [
{
groupTitle: 'Sports',
name: localBChannelName,
url: 'https://streams.example.test/switcher-favorite-b.m3u8',
},
]);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, localAPath);
await importM3uPlaylistFromNativeDialog(app, localBPath);
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, localADisplayName)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await toggleFavoriteForChannel(app.mainWindow, localAChannelName);
await channelItemByTitle(app.mainWindow, localAChannelName)
.first()
.click();
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, localBDisplayName)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await toggleFavoriteForChannel(app.mainWindow, localBChannelName);
await channelItemByTitle(app.mainWindow, localBChannelName)
.first()
.click();
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, localADisplayName)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await openPlaylistFavorites(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
0
);
await switchPlaylistFromHeader(app.mainWindow, localBDisplayName);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
0
);
await openPlaylistRecent(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
0
);
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
await expectPathname(
app.mainWindow,
/\/workspace\/global-recent$/
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
0
);
} finally {
await closeElectronApp(app);
}
});
test('switches away from importing Xtream playlists, returns before completion, and avoids redundant reimport after caching', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [
{
groupTitle: 'News',
name: 'Import Switch Local',
url: 'https://streams.example.test/import-switch-local.m3u8',
},
]);
const app = await launchElectronApp(dataDir, {
env: {
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
},
});
try {
await importM3uPlaylistFromNativeDialog(app, localAPath);
await waitForM3uCatalog(app.mainWindow);
await addXtreamPortal(app.mainWindow, {
name: importingXtreamName,
password: xtreamStressPassword,
username: xtreamStressUsername,
});
const overlay = app.mainWindow.locator('.workspace-loading-overlay');
await expect(overlay).toBeVisible({ timeout: 15000 });
await waitForDbOperationEvent(app.mainWindow, {
operation: 'save-content',
predicate: (event) =>
event.status === 'progress' &&
(event.current ?? 0) > 0 &&
(event.total ?? 0) > 0,
timeoutMs: 30000,
});
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
await waitForM3uCatalog(app.mainWindow);
await expect(overlay).toHaveCount(0);
await switchPlaylistFromHeader(app.mainWindow, importingXtreamName);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/vod$/
);
await Promise.race([
overlay.waitFor({ state: 'visible', timeout: 15000 }),
waitForXtreamCatalog(app.mainWindow),
]);
await waitForXtreamCatalog(app.mainWindow);
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
await waitForM3uCatalog(app.mainWindow);
const startedBeforeReturn = await countDbEvents(
app.mainWindow,
'save-content',
'started'
);
await switchPlaylistFromHeader(app.mainWindow, importingXtreamName);
await waitForXtreamCatalog(app.mainWindow);
await expect(overlay).toHaveCount(0);
await app.mainWindow.waitForTimeout(1200);
const startedAfterReturn = await countDbEvents(
app.mainWindow,
'save-content',
'started'
);
expect(startedAfterReturn).toBe(startedBeforeReturn);
} finally {
await closeElectronApp(app);
}
});
test('keeps cached Xtream content browseable with an unavailable warning after the portal goes offline and after restart', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const offlineLocalPath = writeTemporaryM3uFile(
dataDir,
offlineLocalFileName,
[
{
groupTitle: 'News',
name: 'Offline Local Channel',
url: 'https://streams.example.test/offline-local.m3u8',
},
]
);
let app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await selectSettingsOption(
app.mainWindow,
'select-startup-behavior',
'restore-last-view'
);
await saveSettings(app.mainWindow);
await addXtreamPortal(app.mainWindow, {
name: offlineXtreamName,
password: defaultXtreamPassword,
username: defaultXtreamUsername,
});
await openWorkspaceSection(app.mainWindow, 'Movies');
await waitForXtreamCatalog(app.mainWindow);
await importM3uPlaylistFromNativeDialog(app, offlineLocalPath);
await waitForM3uCatalog(app.mainWindow);
await openSources(app.mainWindow);
const dialog = await openSourceEditor(
app.mainWindow,
offlineXtreamName
);
await updateSourceDialog(dialog, {
serverUrl: xtreamMockServer.replace(/:\d+$/, ':65530'),
});
await saveSourceDialog(app.mainWindow, dialog);
await sourceRowByTitle(app.mainWindow, offlineLocalDisplayName)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await switchPlaylistFromHeader(app.mainWindow, offlineXtreamName);
await waitForXtreamCatalog(app.mainWindow);
await expectXtreamUnavailableWarning(app.mainWindow);
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount(
0
);
app = await restartElectronApp(app, dataDir);
await waitForXtreamCatalog(app.mainWindow);
await expectXtreamUnavailableWarning(app.mainWindow);
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount(
0
);
} finally {
await closeElectronApp(app);
}
});
});
async function openPlaylistSwitcher(page: Page): Promise<void> {
await page
.locator('app-playlist-switcher .playlist-switcher-trigger')
.click();
await expect(
page.locator('.cdk-overlay-pane .playlist-item').first()
).toBeVisible();
}
async function switchPlaylistFromHeader(
page: Page,
title: string
): Promise<void> {
await openPlaylistSwitcher(page);
const item = page
.locator('.cdk-overlay-pane .playlist-item')
.filter({ hasText: title })
.first();
await expect(item).toBeVisible();
await item.click();
await expect(page.locator('.cdk-overlay-pane .playlist-item')).toHaveCount(
0
);
}
async function expectPathname(page: Page, pattern: RegExp): Promise<void> {
await expect
.poll(() => new URL(page.url()).pathname, { timeout: 20000 })
.toMatch(pattern);
}
async function countDbEvents(
page: Page,
operation: string,
status: string
): Promise<number> {
return page.evaluate(
({ operation, status: desiredStatus }) =>
(window.__dbOperationEvents ?? []).filter(
(event) =>
event.operation === operation &&
event.status === desiredStatus
).length,
{ operation, status }
);
}
async function selectSettingsOption(
page: Page,
selectTestId: string,
optionTestId: string
): Promise<void> {
await page.getByTestId(selectTestId).click();
await page.getByTestId(optionTestId).click();
}
async function expectXtreamUnavailableWarning(page: Page): Promise<void> {
await expect(
page.getByTestId('xtream-offline-warning')
).toBeVisible({ timeout: 20000 });
}
async function toggleFavoriteForChannel(page: Page, title: string): Promise<void> {
const item = channelItemByTitle(page, title).first();
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(/star/);
}