mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
* fix(import): consistent add-source forms with masked passwords and URL errors - Mask the Xtream password in add and edit (and the Stalker one in edit) behind a shared PasswordVisibilityToggleDirective: one translated "Show password" label, state in aria-pressed, type="button". - Give the Xtream server URL its own mat-error and a neutral hint instead of the EPG file error; give the M3U URL a mat-error. - Use "Playlist title" in every add form, "MAC address" casing, a single ellipsis in "Validating portal…" and one "Add playlist" submit label; translate the method radiogroup's aria-label. - Show Stalker refusals inline under the portal URL (role="status", like the Xtream connection test), translated in the template and cleared by edits; translate the snackbars for outcomes that close the dialog. - Translate new strings into all locales; reuse the identical Stalker URL error translations; fix MAC casing and ellipses; drop unused keys. - Unit specs per form, edit-dialog spec, new add-source-forms web E2E; update E2E locators; UI guidelines Forms section; Stalker contract. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(import): mask the password again when an add form is cleared Clear erased the password but left the visibility toggle on, so the next password typed in the Xtream or Stalker form showed in plain text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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 playlist', 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();
|
|
}
|
|
});
|
|
});
|