mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
505 lines
18 KiB
TypeScript
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/);
|
|
}
|