mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
915 lines
34 KiB
TypeScript
915 lines
34 KiB
TypeScript
import { createServer } from 'node:http';
|
|
import {
|
|
xtreamMockServer,
|
|
addStalkerPortal,
|
|
addXtreamPortal,
|
|
closeElectronApp,
|
|
createMutableTextServer,
|
|
deleteSource,
|
|
dropM3uPlaylistOntoWorkspace,
|
|
dragSourceBefore,
|
|
expect,
|
|
expectPlaylistUpdatedToast,
|
|
expectSourceDialogValues,
|
|
getVisibleSourceTitles,
|
|
goToDashboard,
|
|
importM3uPlaylistFromNativeDialog,
|
|
importM3uPlaylistFromUrl,
|
|
launchElectronApp,
|
|
openSources,
|
|
openAddPlaylistDialog,
|
|
openSourceEditor,
|
|
refreshSource,
|
|
resetMockServers,
|
|
restartElectronApp,
|
|
saveSourceDialog,
|
|
selectSourceSort,
|
|
selectSourceTypeFilter,
|
|
sourceRowByTitle,
|
|
test,
|
|
updateSourceDialog,
|
|
waitForM3uCatalog,
|
|
waitForPortalDebugEvent,
|
|
waitForXtreamCatalog,
|
|
waitForSourceRowIdle,
|
|
writeTemporaryM3uFile,
|
|
} from './electron-test-fixtures';
|
|
|
|
import {
|
|
configureLiveFormat,
|
|
expectLiveFormatPlaying,
|
|
liveChannels,
|
|
liveFormatMock,
|
|
} from './xtream-live-format.fixture';
|
|
|
|
const localSourceFileName = 'alpha-local-source.m3u';
|
|
const localSourceDisplayName = 'alpha-local-source';
|
|
const urlSourceFileName = 'omega-url-source.m3u';
|
|
const xtreamSourceName = 'Charlie Xtream Portal';
|
|
const stalkerSourceName = 'Bravo Stalker Portal';
|
|
const editableLocalSourceDisplayName = 'editable-local-source';
|
|
const deletableLocalSourceDisplayName = 'deletable-local-source';
|
|
const refreshLocalSourceDisplayName = 'refresh-local-source';
|
|
|
|
test.describe('Electron Sources View', () => {
|
|
test('detects HTTP on Test connection and persists it for refresh, EPG and playback', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
test.setTimeout(150_000);
|
|
await resetMockServers(request, ['xtream']);
|
|
const app = await launchElectronApp(dataDir, {
|
|
args: ['--autoplay-policy=no-user-gesture-required'],
|
|
});
|
|
const title = 'HTTP protocol discovery';
|
|
const httpsUrl = liveFormatMock.replace('http:', 'https:');
|
|
try {
|
|
let page = app.mainWindow;
|
|
await configureLiveFormat(page, 'html5', 'ts');
|
|
await openAddPlaylistDialog(page);
|
|
let dialog = page.getByRole('dialog');
|
|
await dialog
|
|
.getByRole('radio', { name: /Xtream credentials/i })
|
|
.click();
|
|
await dialog.locator('#title').fill(title);
|
|
await dialog.locator('#serverUrl').fill(httpsUrl);
|
|
await dialog.locator('#username').fill('live-fallback');
|
|
await dialog.locator('#password').fill('live-fallback');
|
|
await dialog
|
|
.getByRole('button', {
|
|
name: 'Test HTTPS and HTTP',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(dialog.getByRole('status')).toContainText(
|
|
'Connected using HTTP'
|
|
);
|
|
await expect(dialog.locator('#serverUrl')).toHaveValue(
|
|
liveFormatMock
|
|
);
|
|
await dialog.screenshot({
|
|
path: test.info().outputPath('http-connection-add.png'),
|
|
});
|
|
await dialog
|
|
.getByRole('button', { name: 'Add', exact: true })
|
|
.click();
|
|
await waitForXtreamCatalog(page);
|
|
await openSources(page);
|
|
dialog = await openSourceEditor(page, title);
|
|
await dialog.locator('[formControlName="password"]').fill('');
|
|
await dialog
|
|
.getByRole('button', {
|
|
name: 'Test HTTPS and HTTP',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(dialog.getByRole('status')).toContainText(
|
|
'Enter a username and password'
|
|
);
|
|
await dialog
|
|
.locator('[formControlName="password"]')
|
|
.fill('live-fallback');
|
|
// Closing a tested edit must not persist unrelated changes.
|
|
await updateSourceDialog(dialog, {
|
|
title: 'Discard this edit',
|
|
serverUrl: httpsUrl,
|
|
});
|
|
await dialog
|
|
.getByRole('button', {
|
|
name: 'Test HTTPS and HTTP',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(dialog.getByRole('status')).toContainText(
|
|
'Connected using HTTP'
|
|
);
|
|
await dialog
|
|
.getByRole('button', { name: 'Close', exact: true })
|
|
.click();
|
|
await dialog.waitFor({ state: 'detached' });
|
|
dialog = await openSourceEditor(page, title);
|
|
await expectSourceDialogValues(dialog, {
|
|
title,
|
|
serverUrl: liveFormatMock,
|
|
});
|
|
// Persist a broken HTTPS source, then repair it through Edit.
|
|
await updateSourceDialog(dialog, { serverUrl: httpsUrl });
|
|
await saveSourceDialog(page, dialog);
|
|
dialog = await openSourceEditor(page, title);
|
|
await expectSourceDialogValues(dialog, { serverUrl: httpsUrl });
|
|
await dialog
|
|
.getByRole('button', {
|
|
name: 'Test HTTPS and HTTP',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(dialog.getByRole('status')).toContainText(
|
|
'Connected using HTTP'
|
|
);
|
|
await dialog.screenshot({
|
|
path: test.info().outputPath('http-connection-edit.png'),
|
|
});
|
|
await saveSourceDialog(page, dialog);
|
|
const restarted = await restartElectronApp(app, dataDir);
|
|
app.electronApp = restarted.electronApp;
|
|
app.mainWindow = restarted.mainWindow;
|
|
page = app.mainWindow;
|
|
await openSources(page);
|
|
dialog = await openSourceEditor(page, title);
|
|
await expectSourceDialogValues(dialog, {
|
|
serverUrl: liveFormatMock,
|
|
});
|
|
await dialog
|
|
.getByRole('button', { name: 'Close', exact: true })
|
|
.click();
|
|
await dialog.waitFor({ state: 'detached' });
|
|
const saved = await page.evaluate(
|
|
async (title) =>
|
|
(await window.electron.dbGetAppPlaylistMetas()).find(
|
|
(p) => p.title === title
|
|
),
|
|
title
|
|
);
|
|
expect(saved?.serverUrl).toBe(liveFormatMock);
|
|
await refreshSource(page, title, { confirm: true });
|
|
// Xtream refresh leaves Sources to re-import the catalog.
|
|
await waitForXtreamCatalog(page);
|
|
const refreshed = await waitForPortalDebugEvent(page, {
|
|
provider: 'xtream',
|
|
operation: 'get_live_streams',
|
|
});
|
|
expect(refreshed.request).toMatchObject({
|
|
url: expect.stringMatching(/^http:/),
|
|
});
|
|
await openSources(page);
|
|
await waitForSourceRowIdle(page, title);
|
|
await sourceRowByTitle(page, title).first().click();
|
|
await page
|
|
.getByRole('link', { name: 'Live TV', exact: true })
|
|
.click();
|
|
await page.locator('.context-panel .category-item').first().click();
|
|
const media = page.waitForResponse(
|
|
(r) =>
|
|
r.url().startsWith(liveFormatMock + '/live/') &&
|
|
r.url().endsWith('.ts')
|
|
);
|
|
await liveChannels(page).first().click();
|
|
expect((await media).status()).toBe(200);
|
|
await expectLiveFormatPlaying(page, 'html5');
|
|
const epg = await waitForPortalDebugEvent(page, {
|
|
provider: 'xtream',
|
|
operation: 'get_short_epg',
|
|
});
|
|
expect(epg.request).toMatchObject({
|
|
url: expect.stringMatching(/^http:/),
|
|
});
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('filters and sorts sources, including persisted custom order', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream', 'stalker']);
|
|
const localFilePath = writeTemporaryM3uFile(
|
|
dataDir,
|
|
localSourceFileName,
|
|
[
|
|
{
|
|
groupTitle: 'News',
|
|
name: 'Alpha Local News',
|
|
url: 'https://streams.example.test/local-alpha.m3u8',
|
|
},
|
|
]
|
|
);
|
|
const urlServer = await createMutableTextServer(
|
|
`#EXTM3U
|
|
#EXTINF:-1 group-title="Sports",Omega URL Sports
|
|
https://streams.example.test/url-omega.m3u8
|
|
`,
|
|
{
|
|
contentType: 'application/vnd.apple.mpegurl',
|
|
resourcePath: `/${urlSourceFileName}`,
|
|
}
|
|
);
|
|
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await importM3uPlaylistFromNativeDialog(app, localFilePath);
|
|
await importM3uPlaylistFromUrl(
|
|
app.mainWindow,
|
|
urlServer.resourceUrl
|
|
);
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: xtreamSourceName,
|
|
});
|
|
await addStalkerPortal(app.mainWindow, {
|
|
name: stalkerSourceName,
|
|
});
|
|
await openSources(app.mainWindow);
|
|
|
|
await expect
|
|
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
|
.toHaveLength(4);
|
|
|
|
await selectSourceTypeFilter(app.mainWindow, 'M3U');
|
|
await expect
|
|
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
|
.toEqual([urlSourceFileName, localSourceDisplayName]);
|
|
|
|
await selectSourceTypeFilter(app.mainWindow, 'Xtream');
|
|
await expect
|
|
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
|
.toEqual([xtreamSourceName]);
|
|
|
|
await selectSourceTypeFilter(app.mainWindow, 'Stalker');
|
|
await expect
|
|
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
|
.toEqual([stalkerSourceName]);
|
|
|
|
await selectSourceTypeFilter(app.mainWindow, 'All');
|
|
|
|
await selectSourceSort(app.mainWindow, 'Date added (Oldest first)');
|
|
await expect
|
|
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
|
.toEqual([
|
|
localSourceDisplayName,
|
|
urlSourceFileName,
|
|
xtreamSourceName,
|
|
stalkerSourceName,
|
|
]);
|
|
|
|
await selectSourceSort(app.mainWindow, 'Name (A-Z)');
|
|
await expect
|
|
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
|
.toEqual([
|
|
localSourceDisplayName,
|
|
stalkerSourceName,
|
|
xtreamSourceName,
|
|
urlSourceFileName,
|
|
]);
|
|
|
|
await selectSourceSort(app.mainWindow, 'Name (Z-A)');
|
|
await expect
|
|
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
|
.toEqual([
|
|
urlSourceFileName,
|
|
xtreamSourceName,
|
|
stalkerSourceName,
|
|
localSourceDisplayName,
|
|
]);
|
|
|
|
await selectSourceSort(app.mainWindow, 'Date added (Newest first)');
|
|
await expect
|
|
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
|
.toEqual([
|
|
stalkerSourceName,
|
|
xtreamSourceName,
|
|
urlSourceFileName,
|
|
localSourceDisplayName,
|
|
]);
|
|
|
|
await selectSourceSort(app.mainWindow, 'Custom order');
|
|
await dragSourceBefore(
|
|
app.mainWindow,
|
|
xtreamSourceName,
|
|
localSourceDisplayName
|
|
);
|
|
await expect
|
|
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
|
.toEqual([
|
|
xtreamSourceName,
|
|
localSourceDisplayName,
|
|
urlSourceFileName,
|
|
stalkerSourceName,
|
|
]);
|
|
|
|
await app.mainWindow
|
|
.getByRole('link', { name: 'Dashboard', exact: true })
|
|
.click();
|
|
await openSources(app.mainWindow);
|
|
await expect
|
|
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
|
.toEqual([
|
|
xtreamSourceName,
|
|
localSourceDisplayName,
|
|
urlSourceFileName,
|
|
stalkerSourceName,
|
|
]);
|
|
|
|
const restarted = await restartElectronApp(app, dataDir);
|
|
app.electronApp = restarted.electronApp;
|
|
app.mainWindow = restarted.mainWindow;
|
|
|
|
await openSources(app.mainWindow);
|
|
await expect
|
|
.poll(() => getVisibleSourceTitles(app.mainWindow))
|
|
.toEqual([
|
|
xtreamSourceName,
|
|
localSourceDisplayName,
|
|
urlSourceFileName,
|
|
stalkerSourceName,
|
|
]);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
await urlServer.close();
|
|
}
|
|
});
|
|
|
|
test('edits M3U, Xtream, and Stalker source details and keeps them after restart', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream', 'stalker']);
|
|
const localFilePath = writeTemporaryM3uFile(
|
|
dataDir,
|
|
'editable-local-source.m3u',
|
|
[
|
|
{
|
|
groupTitle: 'Movies',
|
|
name: 'Editable Local Channel',
|
|
url: 'https://streams.example.test/editable-local.m3u8',
|
|
},
|
|
]
|
|
);
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await importM3uPlaylistFromNativeDialog(app, localFilePath);
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: 'Editable Xtream Source',
|
|
});
|
|
await addStalkerPortal(app.mainWindow, {
|
|
name: 'Editable Stalker Source',
|
|
});
|
|
await openSources(app.mainWindow);
|
|
|
|
let dialog = await openSourceEditor(
|
|
app.mainWindow,
|
|
editableLocalSourceDisplayName
|
|
);
|
|
await updateSourceDialog(dialog, {
|
|
autoRefresh: true,
|
|
title: 'Edited Local Source',
|
|
userAgent: 'Electron E2E Local/1.0',
|
|
});
|
|
await saveSourceDialog(app.mainWindow, dialog);
|
|
|
|
dialog = await openSourceEditor(
|
|
app.mainWindow,
|
|
'Editable Xtream Source'
|
|
);
|
|
await updateSourceDialog(dialog, {
|
|
password: 'pass1',
|
|
serverUrl: 'http://127.0.0.1:3211',
|
|
title: 'Edited Xtream Source',
|
|
username: 'user1',
|
|
});
|
|
await saveSourceDialog(app.mainWindow, dialog);
|
|
|
|
dialog = await openSourceEditor(
|
|
app.mainWindow,
|
|
'Editable Stalker Source'
|
|
);
|
|
await updateSourceDialog(dialog, {
|
|
macAddress: '00:1A:79:00:00:99',
|
|
portalUrl: 'http://127.0.0.1:3210/portal.php',
|
|
title: 'Edited Stalker Source',
|
|
});
|
|
await saveSourceDialog(app.mainWindow, dialog);
|
|
|
|
dialog = await openSourceEditor(
|
|
app.mainWindow,
|
|
'Edited Local Source'
|
|
);
|
|
await expectSourceDialogValues(dialog, {
|
|
autoRefresh: true,
|
|
title: 'Edited Local Source',
|
|
userAgent: 'Electron E2E Local/1.0',
|
|
});
|
|
await dialog
|
|
.getByRole('button', { name: 'Close', exact: true })
|
|
.click();
|
|
|
|
dialog = await openSourceEditor(
|
|
app.mainWindow,
|
|
'Edited Xtream Source'
|
|
);
|
|
await expectSourceDialogValues(dialog, {
|
|
password: 'pass1',
|
|
serverUrl: 'http://127.0.0.1:3211',
|
|
title: 'Edited Xtream Source',
|
|
username: 'user1',
|
|
});
|
|
await dialog
|
|
.getByRole('button', { name: 'Close', exact: true })
|
|
.click();
|
|
|
|
dialog = await openSourceEditor(
|
|
app.mainWindow,
|
|
'Edited Stalker Source'
|
|
);
|
|
await expectSourceDialogValues(dialog, {
|
|
macAddress: '00:1A:79:00:00:99',
|
|
portalUrl: 'http://127.0.0.1:3210/portal.php',
|
|
title: 'Edited Stalker Source',
|
|
});
|
|
await dialog
|
|
.getByRole('button', { name: 'Close', exact: true })
|
|
.click();
|
|
|
|
const restarted = await restartElectronApp(app, dataDir);
|
|
app.electronApp = restarted.electronApp;
|
|
app.mainWindow = restarted.mainWindow;
|
|
|
|
await openSources(app.mainWindow);
|
|
|
|
dialog = await openSourceEditor(
|
|
app.mainWindow,
|
|
'Edited Local Source'
|
|
);
|
|
await expectSourceDialogValues(dialog, {
|
|
autoRefresh: true,
|
|
title: 'Edited Local Source',
|
|
userAgent: 'Electron E2E Local/1.0',
|
|
});
|
|
await dialog
|
|
.getByRole('button', { name: 'Close', exact: true })
|
|
.click();
|
|
|
|
dialog = await openSourceEditor(
|
|
app.mainWindow,
|
|
'Edited Xtream Source'
|
|
);
|
|
await expectSourceDialogValues(dialog, {
|
|
password: 'pass1',
|
|
serverUrl: 'http://127.0.0.1:3211',
|
|
title: 'Edited Xtream Source',
|
|
username: 'user1',
|
|
});
|
|
await dialog
|
|
.getByRole('button', { name: 'Close', exact: true })
|
|
.click();
|
|
|
|
dialog = await openSourceEditor(
|
|
app.mainWindow,
|
|
'Edited Stalker Source'
|
|
);
|
|
await expectSourceDialogValues(dialog, {
|
|
macAddress: '00:1A:79:00:00:99',
|
|
portalUrl: 'http://127.0.0.1:3210/portal.php',
|
|
title: 'Edited Stalker Source',
|
|
});
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('skips a source whose startup auto-refresh is still fetching', async ({
|
|
dataDir,
|
|
}) => {
|
|
let requests = 0;
|
|
const server = createServer(() => {
|
|
requests++;
|
|
});
|
|
await new Promise<void>((resolve) =>
|
|
server.listen(0, '127.0.0.1', resolve)
|
|
);
|
|
const address = server.address();
|
|
if (!address || typeof address === 'string')
|
|
throw new Error('Missing server address');
|
|
const url = `http://127.0.0.1:${address.port}/slow.m3u`;
|
|
let app = await launchElectronApp(dataDir);
|
|
try {
|
|
await app.mainWindow.evaluate(async (url) => {
|
|
await window.electron.dbUpsertAppPlaylist({
|
|
_id: 'startup-busy',
|
|
title: 'Startup busy source',
|
|
url,
|
|
count: 0,
|
|
importDate: '2026-01-01',
|
|
lastUsage: '2026-01-01',
|
|
autoRefresh: true,
|
|
});
|
|
}, url);
|
|
app = await restartElectronApp(app, dataDir);
|
|
await expect.poll(() => requests).toBeGreaterThan(0);
|
|
await openSources(app.mainWindow);
|
|
await app.mainWindow
|
|
.getByRole('button', {
|
|
name: 'Clean up inactive sources…',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
const dialog = app.mainWindow.getByRole('dialog');
|
|
const row = dialog.locator('[data-source-id="startup-busy"]');
|
|
await expect(row).toHaveAttribute('data-status', 'skipped');
|
|
await expect(row.getByRole('checkbox')).toBeDisabled();
|
|
expect(
|
|
await app.mainWindow.evaluate(async () =>
|
|
(await window.electron.dbGetAppPlaylists()).some(
|
|
(p) => p._id === 'startup-busy'
|
|
)
|
|
)
|
|
).toBe(true);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
server.closeAllConnections();
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
test('cleans selected inactive sources across filters and preserves unselected SQLite data after restart', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
const urlServer = await createMutableTextServer(
|
|
'<html>Temporarily unavailable</html>',
|
|
{ resourcePath: '/uncertain.m3u' }
|
|
);
|
|
let app = await launchElectronApp(dataDir);
|
|
try {
|
|
await app.mainWindow.evaluate(
|
|
async ({ serverUrl, url }) => {
|
|
const common = {
|
|
count: 0,
|
|
importDate: '2026-01-01',
|
|
lastUsage: '2026-01-01',
|
|
autoRefresh: false,
|
|
};
|
|
for (const id of ['expired-delete', 'expired-keep']) {
|
|
await window.electron.dbUpsertAppPlaylist({
|
|
...common,
|
|
_id: id,
|
|
title: id,
|
|
serverUrl,
|
|
username: 'expired',
|
|
password: 'expired',
|
|
});
|
|
}
|
|
await window.electron.dbUpsertAppPlaylist({
|
|
...common,
|
|
_id: 'local-keep',
|
|
title: 'local-keep',
|
|
favorites: ['preserved-channel'],
|
|
});
|
|
await window.electron.dbUpsertAppPlaylist({
|
|
...common,
|
|
_id: 'uncertain-keep',
|
|
title: 'uncertain-keep',
|
|
url,
|
|
});
|
|
},
|
|
{ serverUrl: xtreamMockServer, url: urlServer.resourceUrl }
|
|
);
|
|
app = await restartElectronApp(app, dataDir);
|
|
await openSources(app.mainWindow);
|
|
await selectSourceTypeFilter(app.mainWindow, 'M3U');
|
|
await app.mainWindow
|
|
.getByRole('button', {
|
|
name: 'Clean up inactive sources…',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
const dialog = app.mainWindow.getByRole('dialog');
|
|
await expect(
|
|
dialog.getByRole('checkbox', {
|
|
name: 'expired-delete',
|
|
exact: true,
|
|
})
|
|
).toBeChecked({ timeout: 20000 });
|
|
await expect(
|
|
dialog.getByRole('checkbox', {
|
|
name: 'expired-keep',
|
|
exact: true,
|
|
})
|
|
).toBeChecked();
|
|
await expect(
|
|
dialog.getByRole('checkbox', {
|
|
name: 'uncertain-keep',
|
|
exact: true,
|
|
})
|
|
).not.toBeChecked();
|
|
await expect(
|
|
dialog.getByRole('checkbox', {
|
|
name: 'local-keep',
|
|
exact: true,
|
|
})
|
|
).toHaveCount(0);
|
|
await app.mainWindow.screenshot({
|
|
path: '/tmp/iptvnator-source-cleanup-light.png',
|
|
animations: 'disabled',
|
|
});
|
|
await app.mainWindow.evaluate(() =>
|
|
document.body.classList.add('dark-theme')
|
|
);
|
|
await app.mainWindow.screenshot({
|
|
path: '/tmp/iptvnator-source-cleanup-dark.png',
|
|
animations: 'disabled',
|
|
});
|
|
await dialog
|
|
.getByRole('checkbox', { name: 'expired-keep', exact: true })
|
|
.uncheck();
|
|
await dialog
|
|
.getByRole('button', {
|
|
name: 'Delete selected · 1',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(
|
|
dialog.locator('[data-source-id="expired-delete"]')
|
|
).toHaveAttribute('data-status', 'deleted', { timeout: 20000 });
|
|
await dialog
|
|
.getByRole('button', { name: 'Close', exact: true })
|
|
.click();
|
|
app = await restartElectronApp(app, dataDir);
|
|
const persisted = await app.mainWindow.evaluate(async () => {
|
|
const playlists = await window.electron.dbGetAppPlaylists();
|
|
return {
|
|
ids: playlists.map((p) => p._id),
|
|
favorites: playlists.find((p) => p._id === 'local-keep')
|
|
?.favorites,
|
|
};
|
|
});
|
|
expect(persisted.ids.sort()).toEqual([
|
|
'expired-keep',
|
|
'local-keep',
|
|
'uncertain-keep',
|
|
]);
|
|
expect(persisted.favorites).toEqual(['preserved-channel']);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
await urlServer.close();
|
|
}
|
|
});
|
|
|
|
test('deletes M3U, Stalker, and Xtream sources from the sources view', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream', 'stalker']);
|
|
const localFilePath = writeTemporaryM3uFile(
|
|
dataDir,
|
|
'deletable-local-source.m3u',
|
|
[
|
|
{
|
|
groupTitle: 'Live',
|
|
name: 'Delete Me Local',
|
|
url: 'https://streams.example.test/delete-local.m3u8',
|
|
},
|
|
]
|
|
);
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await importM3uPlaylistFromNativeDialog(app, localFilePath);
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: 'Delete Me Xtream',
|
|
});
|
|
await addStalkerPortal(app.mainWindow, {
|
|
name: 'Delete Me Stalker',
|
|
});
|
|
await openSources(app.mainWindow);
|
|
|
|
await expect(
|
|
sourceRowByTitle(app.mainWindow, 'Delete Me Stalker').locator(
|
|
'app-source-health-indicator [data-state]'
|
|
)
|
|
).toHaveAttribute('data-state', 'active', { timeout: 20000 });
|
|
await expect(
|
|
sourceRowByTitle(app.mainWindow, 'Delete Me Xtream').locator(
|
|
'app-source-health-indicator [data-state]'
|
|
)
|
|
).toHaveAttribute('data-state', 'active', { timeout: 20000 });
|
|
|
|
await deleteSource(app.mainWindow, deletableLocalSourceDisplayName);
|
|
await expect(
|
|
sourceRowByTitle(
|
|
app.mainWindow,
|
|
deletableLocalSourceDisplayName
|
|
)
|
|
).toHaveCount(0, {
|
|
timeout: 20000,
|
|
});
|
|
|
|
await deleteSource(app.mainWindow, 'Delete Me Stalker');
|
|
await expect(
|
|
sourceRowByTitle(app.mainWindow, 'Delete Me Stalker')
|
|
).toHaveCount(0, {
|
|
timeout: 20000,
|
|
});
|
|
|
|
await deleteSource(app.mainWindow, 'Delete Me Xtream');
|
|
await expect(
|
|
sourceRowByTitle(app.mainWindow, 'Delete Me Xtream')
|
|
).toHaveCount(0, {
|
|
timeout: 60000,
|
|
});
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('refreshes local-file M3U, URL M3U, and Xtream sources from the sources view', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
const localFilePath = writeTemporaryM3uFile(
|
|
dataDir,
|
|
'refresh-local-source.m3u',
|
|
[
|
|
{
|
|
groupTitle: 'News',
|
|
name: 'Original Local Channel',
|
|
url: 'https://streams.example.test/original-local.m3u8',
|
|
},
|
|
]
|
|
);
|
|
const urlServer = await createMutableTextServer(
|
|
`#EXTM3U
|
|
#EXTINF:-1 group-title="News",Original URL Channel
|
|
https://streams.example.test/original-url.m3u8
|
|
`,
|
|
{
|
|
contentType: 'application/vnd.apple.mpegurl',
|
|
resourcePath: '/refresh-url-source.m3u',
|
|
}
|
|
);
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await dropM3uPlaylistOntoWorkspace(app.mainWindow, localFilePath);
|
|
await waitForM3uCatalog(app.mainWindow);
|
|
await expect(
|
|
app.mainWindow.locator(
|
|
'app-playlist-switcher .trigger-refresh-button'
|
|
)
|
|
).toBeVisible();
|
|
|
|
await goToDashboard(app.mainWindow);
|
|
const sourceCard = app.mainWindow
|
|
.getByTestId('dashboard-recent-sources-rail-card')
|
|
.filter({
|
|
hasText: refreshLocalSourceDisplayName,
|
|
})
|
|
.first();
|
|
await expect(sourceCard).toBeVisible({ timeout: 20000 });
|
|
await sourceCard.hover();
|
|
await sourceCard
|
|
.getByTestId('dashboard-recent-sources-rail-card-actions')
|
|
.click();
|
|
await expect(
|
|
app.mainWindow.getByRole('menuitem', {
|
|
name: 'Refresh playlist',
|
|
exact: true,
|
|
})
|
|
).toBeVisible();
|
|
await app.mainWindow.keyboard.press('Escape');
|
|
|
|
await importM3uPlaylistFromUrl(
|
|
app.mainWindow,
|
|
urlServer.resourceUrl
|
|
);
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: 'Refresh Xtream Source',
|
|
});
|
|
await openSources(app.mainWindow);
|
|
await expect(
|
|
sourceRowByTitle(app.mainWindow, refreshLocalSourceDisplayName)
|
|
.first()
|
|
.locator('.refresh-btn')
|
|
).toBeVisible();
|
|
|
|
writeTemporaryM3uFile(dataDir, 'refresh-local-source.m3u', [
|
|
{
|
|
groupTitle: 'News',
|
|
name: 'Refreshed Local Channel',
|
|
url: 'https://streams.example.test/refreshed-local.m3u8',
|
|
},
|
|
]);
|
|
await refreshSource(app.mainWindow, refreshLocalSourceDisplayName);
|
|
await expectPlaylistUpdatedToast(app.mainWindow);
|
|
await sourceRowByTitle(
|
|
app.mainWindow,
|
|
refreshLocalSourceDisplayName
|
|
)
|
|
.first()
|
|
.click();
|
|
await waitForM3uCatalog(app.mainWindow);
|
|
await expect(
|
|
app.mainWindow.getByTestId('channel-item').filter({
|
|
hasText: 'Refreshed Local Channel',
|
|
})
|
|
).toHaveCount(1);
|
|
await openSources(app.mainWindow);
|
|
|
|
urlServer.setBody(
|
|
`#EXTM3U
|
|
#EXTINF:-1 group-title="News",Refreshed URL Channel
|
|
https://streams.example.test/refreshed-url.m3u8
|
|
`
|
|
);
|
|
// A previous refresh toast must not satisfy this operation's completion.
|
|
await expect(
|
|
app.mainWindow.locator('.mat-mdc-snack-bar-label')
|
|
).toHaveCount(0);
|
|
await refreshSource(app.mainWindow, 'refresh-url-source.m3u');
|
|
await expectPlaylistUpdatedToast(app.mainWindow);
|
|
// Wait for this source's first refresh to be reflected in its row
|
|
// before checking idle and opening it.
|
|
await expect(
|
|
sourceRowByTitle(app.mainWindow, 'refresh-url-source.m3u')
|
|
.first()
|
|
.locator('.meta')
|
|
).toContainText('Updated:');
|
|
await waitForSourceRowIdle(
|
|
app.mainWindow,
|
|
'refresh-url-source.m3u'
|
|
);
|
|
const refreshedUrlRow = sourceRowByTitle(
|
|
app.mainWindow,
|
|
'refresh-url-source.m3u'
|
|
).first();
|
|
await expect(
|
|
refreshedUrlRow.locator('.playlist-item')
|
|
).not.toHaveClass(/is-busy/);
|
|
await expect(refreshedUrlRow.locator('.refresh-btn')).toBeEnabled();
|
|
await refreshedUrlRow
|
|
.getByText('refresh-url-source.m3u', { exact: true })
|
|
.click();
|
|
await waitForM3uCatalog(app.mainWindow);
|
|
await expect(
|
|
app.mainWindow.getByTestId('channel-item').filter({
|
|
hasText: 'Refreshed URL Channel',
|
|
})
|
|
).toHaveCount(1);
|
|
await openSources(app.mainWindow);
|
|
|
|
await refreshSource(app.mainWindow, 'Refresh Xtream Source', {
|
|
confirm: true,
|
|
});
|
|
await waitForXtreamCatalog(app.mainWindow);
|
|
await waitForPortalDebugEvent(app.mainWindow, {
|
|
operation: 'get_live_categories',
|
|
provider: 'xtream',
|
|
timeoutMs: 30000,
|
|
});
|
|
await openSources(app.mainWindow);
|
|
await waitForSourceRowIdle(app.mainWindow, 'Refresh Xtream Source');
|
|
await expect(
|
|
sourceRowByTitle(app.mainWindow, 'Refresh Xtream Source')
|
|
.first()
|
|
.locator('.meta')
|
|
).toContainText('Updated:');
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
await urlServer.close();
|
|
}
|
|
});
|
|
});
|