mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(epg): remove cached XMLTV data after source deletion (#1548)
* fix(epg): remove cached XMLTV data after source deletion * fix(epg): close source reconciliation review races * fix(epg): serialize cleanup with replacement imports * fix(epg): report retired worker exits as cancellations * fix(epg): preserve source metadata through cache cleanup * refactor(epg): separate worker runtime and import lifecycle * fix(epg): skip cleanup for unchanged source settings * fix(epg): cancel retired error rows and pending retries * fix(epg): redact diagnostics and mirror committed settings after cleanup errors * fix(epg): preserve metadata writer order independently of timestamps
This commit is contained in:
1 parent
baba0529ef
commit
9de480826c
55 files changed
+3538
-615
No files matched your search
@@ -1,3 +1,5 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { Page } from '@playwright/test';
|
||||
import {
|
||||
buildM3uContent,
|
||||
@@ -5,6 +7,7 @@ import {
|
||||
closeElectronApp,
|
||||
createMutableTextServer,
|
||||
expect,
|
||||
goToDashboard,
|
||||
importM3uPlaylistFromUrl,
|
||||
launchElectronApp,
|
||||
openWorkspaceSection,
|
||||
@@ -33,7 +36,8 @@ const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
function createCurrentXmltvFixture(
|
||||
channelId: string,
|
||||
channelName: string,
|
||||
programTitle: string
|
||||
programTitle: string,
|
||||
iconUrl?: string
|
||||
): string {
|
||||
const start = new Date(Date.now() - 15 * 60 * 1000);
|
||||
const stop = new Date(Date.now() + 45 * 60 * 1000);
|
||||
@@ -42,6 +46,7 @@ function createCurrentXmltvFixture(
|
||||
<tv>
|
||||
<channel id="${channelId}">
|
||||
<display-name>${channelName}</display-name>
|
||||
${iconUrl ? `<icon src="${iconUrl}"/>` : ''}
|
||||
</channel>
|
||||
<programme start="${formatXmltvDate(start)} +0000" stop="${formatXmltvDate(stop)} +0000" channel="${channelId}">
|
||||
<title>${programTitle}</title>
|
||||
@@ -65,7 +70,159 @@ function formatXmltvDate(date: Date): string {
|
||||
}
|
||||
|
||||
test.describe('Electron EPG', () => {
|
||||
test('@epg @electron adds an EPG source, fetches guide data, removes the source row, and clears stored EPG data', async ({
|
||||
test('@epg @electron saves unrelated settings when EPG reconciliation is unavailable', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'general');
|
||||
// After startup, make any unexpected reconciliation fail.
|
||||
await app.electronApp.evaluate(({ ipcMain }) => {
|
||||
ipcMain.removeHandler('EPG_RECONCILE_SOURCES');
|
||||
ipcMain.handle('EPG_RECONCILE_SOURCES', () => {
|
||||
throw new Error('Synthetic migration failure');
|
||||
});
|
||||
});
|
||||
const guard = app.mainWindow
|
||||
.getByTestId('portal-connectivity-toggle')
|
||||
.locator('input');
|
||||
await guard.uncheck();
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('settings-unsaved-bar')
|
||||
).toBeVisible();
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('settings-unsaved-bar')
|
||||
).not.toBeVisible();
|
||||
await goToDashboard(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('unsaved-dialog-save')
|
||||
).not.toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron mirrors saved desktop settings when source cleanup fails', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await app.electronApp.evaluate(({ ipcMain }) => {
|
||||
ipcMain.removeHandler('EPG_RECONCILE_SOURCES');
|
||||
ipcMain.handle('EPG_RECONCILE_SOURCES', () => {
|
||||
throw new Error('Synthetic cleanup failure');
|
||||
});
|
||||
});
|
||||
await openSettingsSection(app.mainWindow, 'general');
|
||||
await app.mainWindow
|
||||
.getByTestId('portal-connectivity-toggle')
|
||||
.locator('input')
|
||||
.uncheck();
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row input')
|
||||
.fill('https://example.invalid/guide.xml');
|
||||
await app.mainWindow
|
||||
.getByTestId('save-settings')
|
||||
.click({ noWaitAfter: true });
|
||||
await expect(
|
||||
app.mainWindow.getByText(
|
||||
'Failed to clear EPG data. Please try again.',
|
||||
{ exact: true }
|
||||
)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('settings-unsaved-bar')
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const config = JSON.parse(
|
||||
await readFile(
|
||||
join(dataDir, 'config', 'config.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
return config.PORTAL_CONNECTIVITY_GUARD;
|
||||
})
|
||||
.toBe(false);
|
||||
// Restore cleanup so the explicit retry can finish and close normally.
|
||||
await app.electronApp.evaluate(({ ipcMain }) => {
|
||||
ipcMain.removeHandler('EPG_RECONCILE_SOURCES');
|
||||
ipcMain.handle('EPG_RECONCILE_SOURCES', () => ({
|
||||
success: true,
|
||||
removedUrls: [],
|
||||
}));
|
||||
});
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row button')
|
||||
.nth(1)
|
||||
.click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('settings-unsaved-bar')
|
||||
).not.toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron omits source secrets from main and parser-worker diagnostics', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const server = await createMutableTextServer(epgFixtureXml, {
|
||||
contentType: 'application/xml; charset=utf-8',
|
||||
resourcePath: '/epg-path-secret/guide.xml',
|
||||
});
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const sourceUrl = `${server.resourceUrl}?custom=epg-query-secret`;
|
||||
let diagnostics = '';
|
||||
const capture = (chunk: Buffer) => {
|
||||
diagnostics += chunk.toString();
|
||||
};
|
||||
const process = app.electronApp.process();
|
||||
process.stdout?.on('data', capture);
|
||||
process.stderr?.on('data', capture);
|
||||
try {
|
||||
const imported = await app.mainWindow.evaluate(async (url) => {
|
||||
const result = await window.electron.forceFetchEpg(url);
|
||||
await window.electron.clearEpgDataForSource(url);
|
||||
return result;
|
||||
}, sourceUrl);
|
||||
expect(imported.success).toBe(true);
|
||||
await expect
|
||||
.poll(() => diagnostics)
|
||||
.toContain('EPG data cleared for source via worker');
|
||||
const failed = await app.mainWindow.evaluate(async (url) => {
|
||||
const missing = new URL(url);
|
||||
missing.pathname += '/missing';
|
||||
return window.electron.forceFetchEpg(missing.toString());
|
||||
}, sourceUrl);
|
||||
expect(failed.success).toBe(false);
|
||||
await expect
|
||||
.poll(() => diagnostics)
|
||||
.toContain('Request failed with status code 404');
|
||||
// Worker stdout can lose its final buffered line on termination;
|
||||
// the awaited import result and main-process completion are authoritative.
|
||||
expect(diagnostics).toContain('[EPG Worker] Fetching EPG source');
|
||||
expect(diagnostics).toContain('EPG parsing complete:');
|
||||
expect(diagnostics).not.toContain('epg-path-secret');
|
||||
expect(diagnostics).not.toContain('epg-query-secret');
|
||||
expect(await getEpgChannelCount(app.mainWindow)).toBe(0);
|
||||
} finally {
|
||||
process.stdout?.off('data', capture);
|
||||
process.stderr?.off('data', capture);
|
||||
await closeElectronApp(app);
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron adds an EPG source, fetches guide data, removes its stored EPG data on save', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const epgServer = await createMutableTextServer(epgFixtureXml, {
|
||||
@@ -103,6 +260,8 @@ test.describe('Electron EPG', () => {
|
||||
.first()
|
||||
).toBeVisible();
|
||||
|
||||
await saveSettings(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row button')
|
||||
.nth(1)
|
||||
@@ -116,18 +275,6 @@ test.describe('Electron EPG', () => {
|
||||
// would block the close and leak the Electron process.
|
||||
await saveSettings(app.mainWindow);
|
||||
|
||||
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,
|
||||
@@ -139,9 +286,249 @@ test.describe('Electron EPG', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron removes a retained error row after its import worker has finished', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const server = await createMutableTextServer(epgFixtureXml, {
|
||||
contentType: 'application/xml',
|
||||
});
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row input')
|
||||
.fill(server.resourceUrl);
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.locator(
|
||||
'.epg-progress-panel .import-item.status-complete'
|
||||
)
|
||||
).toHaveCount(1, { timeout: 30000 });
|
||||
// Capture the actual generation; a force-fetch also awaits termination.
|
||||
const generation = await app.mainWindow.evaluate(async (url) => {
|
||||
await window.electron.clearEpgDataForSource(url);
|
||||
const completed = new Promise<number>((resolve) => {
|
||||
window.electron.onEpgProgress?.((progress) => {
|
||||
if (
|
||||
progress.url === url &&
|
||||
progress.status === 'complete'
|
||||
)
|
||||
resolve(progress.generation ?? 0);
|
||||
});
|
||||
});
|
||||
await window.electron.forceFetchEpg(url);
|
||||
return completed;
|
||||
}, server.resourceUrl);
|
||||
await app.electronApp.evaluate(
|
||||
({ BrowserWindow }, { url, generation }) => {
|
||||
BrowserWindow.getAllWindows()[0].webContents.send(
|
||||
'EPG_PROGRESS_UPDATE',
|
||||
{
|
||||
url,
|
||||
status: 'error',
|
||||
generation,
|
||||
errorCode: 'invalid-tls-certificate',
|
||||
error: 'Synthetic retained TLS error',
|
||||
}
|
||||
);
|
||||
},
|
||||
{ url: server.resourceUrl, generation }
|
||||
);
|
||||
await expect(
|
||||
app.mainWindow.locator(
|
||||
'.epg-progress-panel .import-item.status-error'
|
||||
)
|
||||
).toHaveCount(1);
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row button')
|
||||
.nth(1)
|
||||
.click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.locator('.epg-progress-panel .import-item')
|
||||
).toHaveCount(0);
|
||||
await expect.poll(() => getEpgChannelCount(app.mainWindow)).toBe(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron removes only the saved source, preserves shared IDs and mappings, and repairs old orphan data on restart', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
const first = await createMutableTextServer(
|
||||
createCurrentXmltvFixture(
|
||||
'shared-news',
|
||||
'Shared News',
|
||||
'Removed Bulletin'
|
||||
),
|
||||
{ contentType: 'application/xml', resourcePath: '/first.xml' }
|
||||
);
|
||||
const second = await createMutableTextServer(
|
||||
createCurrentXmltvFixture(
|
||||
'shared-news',
|
||||
'Shared News',
|
||||
'Retained Bulletin'
|
||||
),
|
||||
{ contentType: 'application/xml', resourcePath: '/second.xml' }
|
||||
);
|
||||
let app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
for (const [index, url] of [
|
||||
first.resourceUrl,
|
||||
second.resourceUrl,
|
||||
].entries()) {
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
const inputs = app.mainWindow.locator('.epg-source-row input');
|
||||
await expect(inputs).toHaveCount(index + 1);
|
||||
await inputs.nth(index).fill(url);
|
||||
}
|
||||
await saveSettings(app.mainWindow);
|
||||
const programs = () =>
|
||||
app.mainWindow.evaluate(async () =>
|
||||
(
|
||||
await window.electron.getChannelPrograms('mapped-news')
|
||||
).map((p) => p.title)
|
||||
);
|
||||
await app.mainWindow.evaluate(() =>
|
||||
window.electron.setEpgMapping('mapped-news', 'shared-news')
|
||||
);
|
||||
await expect
|
||||
.poll(programs, { timeout: 30000 })
|
||||
.toEqual(['Removed Bulletin', 'Retained Bulletin']);
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row')
|
||||
.first()
|
||||
.locator('button')
|
||||
.nth(1)
|
||||
.click();
|
||||
// A staged removal must not delete data before Save.
|
||||
expect(await programs()).toContain('Removed Bulletin');
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect.poll(programs).toEqual(['Retained Bulletin']);
|
||||
await closeElectronApp(app);
|
||||
app = await launchElectronApp(dataDir);
|
||||
await expect.poll(programs).toEqual(['Retained Bulletin']);
|
||||
// Simulate a cache left by 0.23: import a source absent from settings.
|
||||
await app.mainWindow.evaluate(
|
||||
(url) => window.electron.forceFetchEpg(url),
|
||||
first.resourceUrl
|
||||
);
|
||||
await expect.poll(programs).toContain('Removed Bulletin');
|
||||
await closeElectronApp(app);
|
||||
app = await launchElectronApp(dataDir);
|
||||
await expect.poll(programs).toEqual(['Retained Bulletin']);
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row button')
|
||||
.nth(1)
|
||||
.click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect.poll(programs).toEqual([]);
|
||||
await expect.poll(() => getEpgChannelCount(app.mainWindow)).toBe(0);
|
||||
expect(
|
||||
await app.mainWindow.evaluate(() =>
|
||||
window.electron.getEpgMapping('mapped-news')
|
||||
)
|
||||
).toMatchObject({ epgChannelId: 'shared-news' });
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await first.close();
|
||||
await second.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron restores the first source name and logo after removing the last metadata writer', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const first = await createMutableTextServer(
|
||||
createCurrentXmltvFixture(
|
||||
'shared-logo',
|
||||
'First News',
|
||||
'First Bulletin',
|
||||
'https://example.com/first.png'
|
||||
),
|
||||
{ contentType: 'application/xml', resourcePath: '/first.xml' }
|
||||
);
|
||||
const second = await createMutableTextServer(
|
||||
createCurrentXmltvFixture(
|
||||
'shared-logo',
|
||||
'Second News',
|
||||
'Second Bulletin',
|
||||
'https://example.com/second.png'
|
||||
),
|
||||
{ contentType: 'application/xml', resourcePath: '/second.xml' }
|
||||
);
|
||||
let app = await launchElectronApp(dataDir);
|
||||
const metadata = () =>
|
||||
app.mainWindow.evaluate(
|
||||
async () =>
|
||||
(
|
||||
await window.electron.getEpgChannelMetadata([
|
||||
'shared-logo',
|
||||
])
|
||||
)['shared-logo']
|
||||
);
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
for (const [index, source] of [first, second].entries()) {
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
const inputs = app.mainWindow.locator('.epg-source-row input');
|
||||
await expect(inputs).toHaveCount(index + 1);
|
||||
await inputs.nth(index).fill(source.resourceUrl);
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect.poll(metadata, { timeout: 30000 }).toMatchObject({
|
||||
displayName: index === 0 ? 'First News' : 'Second News',
|
||||
iconUrl: `https://example.com/${index === 0 ? 'first' : 'second'}.png`,
|
||||
});
|
||||
}
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row')
|
||||
.nth(1)
|
||||
.locator('button')
|
||||
.nth(1)
|
||||
.click();
|
||||
await saveSettings(app.mainWindow);
|
||||
const firstMetadata = {
|
||||
displayName: 'First News',
|
||||
iconUrl: 'https://example.com/first.png',
|
||||
};
|
||||
await expect.poll(metadata).toMatchObject(firstMetadata);
|
||||
await closeElectronApp(app);
|
||||
app = await launchElectronApp(dataDir);
|
||||
await expect.poll(metadata).toMatchObject(firstMetadata);
|
||||
expect(
|
||||
await app.mainWindow.evaluate(async () =>
|
||||
(
|
||||
await window.electron.getChannelPrograms('shared-logo')
|
||||
).map((p) => p.title)
|
||||
)
|
||||
).toEqual(['First Bulletin']);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await first.close();
|
||||
await second.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @electron imports and renders an EPG source declared by an M3U playlist header', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
test.setTimeout(90000);
|
||||
const epgServer = await createMutableTextServer(
|
||||
createCurrentXmltvFixture(
|
||||
'playlist-guide-news',
|
||||
@@ -212,6 +599,27 @@ test.describe('Electron EPG', () => {
|
||||
'Playlist Scoped Bulletin',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row input')
|
||||
.fill(epgServer.resourceUrl);
|
||||
await saveSettings(app.mainWindow);
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row button')
|
||||
.nth(1)
|
||||
.click();
|
||||
await saveSettings(app.mainWindow);
|
||||
// The same URL still belongs to the saved M3U playlist.
|
||||
const retained = await app.mainWindow.evaluate(async () =>
|
||||
window.electron.getChannelPrograms('playlist-guide-news')
|
||||
);
|
||||
expect(retained.map((program) => program.title)).toContain(
|
||||
'Playlist Scoped Bulletin'
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await playlistServer.close();
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
channelItemByTitle,
|
||||
clickCategoryByNameExact,
|
||||
closeElectronApp,
|
||||
createMutableTextServer,
|
||||
expect,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
@@ -28,6 +29,189 @@ const epgCredentials = {
|
||||
password: 'epg',
|
||||
};
|
||||
|
||||
test('@epg @xtream @electron removes uploaded guide data and restores provider EPG without restarting', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const fixture = await fetchXtreamEpgFixture(request, epgCredentials);
|
||||
const id = fixture.stream.epg_channel_id;
|
||||
expect(id).toBeTruthy();
|
||||
const stamp = (date: Date) =>
|
||||
date.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
||||
const xml = `<tv><channel id="${id}"><display-name>Uploaded Guide</display-name></channel>
|
||||
<programme channel="${id}" start="${stamp(new Date(Date.now() - 600000))} +0000" stop="${stamp(new Date(Date.now() + 3600000))} +0000"><title>Temporary XMLTV Bulletin</title></programme></tv>`;
|
||||
const source = await createMutableTextServer(xml, {
|
||||
contentType: 'application/xml',
|
||||
resourcePath: '/temporary.xml',
|
||||
});
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: epgPortalName,
|
||||
...epgCredentials,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row input')
|
||||
.fill(source.resourceUrl);
|
||||
await app.mainWindow
|
||||
.getByTestId('toggle-prefer-uploaded-epg')
|
||||
.locator('input')
|
||||
.check();
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
app.mainWindow.evaluate(
|
||||
async (channelId) =>
|
||||
(
|
||||
await window.electron.getChannelPrograms(
|
||||
channelId!
|
||||
)
|
||||
).map((p) => p.title),
|
||||
id
|
||||
),
|
||||
{ timeout: 30000 }
|
||||
)
|
||||
.toContain('Temporary XMLTV Bulletin');
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
const row = channelItemByTitle(
|
||||
app.mainWindow,
|
||||
fixture.stream.name ?? ''
|
||||
).first();
|
||||
await expect(row.locator('.epg-title')).toHaveText(
|
||||
'Temporary XMLTV Bulletin'
|
||||
);
|
||||
await row.click();
|
||||
await expect
|
||||
.poll(() => timelineBlockTitles(app.mainWindow))
|
||||
.toContain('Temporary XMLTV Bulletin');
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow.locator('.epg-source-row button').nth(1).click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
await expect(row.locator('.epg-title')).toHaveText(
|
||||
fixture.shortEpg[0].title
|
||||
);
|
||||
await row.click();
|
||||
await expect
|
||||
.poll(() => timelineBlockTitles(app.mainWindow))
|
||||
.not.toContain('Temporary XMLTV Bulletin');
|
||||
await expect
|
||||
.poll(() => timelineBlockTitles(app.mainWindow))
|
||||
.toContain(fixture.fullEpg[0].title);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await source.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @stalker @electron invalidates a loaded manual XMLTV mapping after source removal', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
await resetMockServers(request, ['stalker']);
|
||||
const fixture = await fetchStalkerCategoryFixture(request, 'itv');
|
||||
const item = fixture.items[0];
|
||||
const stamp = (date: Date) =>
|
||||
date.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
||||
const source = await createMutableTextServer(
|
||||
`<tv><channel id="stalker-mapped"><display-name>Mapped Guide</display-name></channel>
|
||||
<programme channel="stalker-mapped" start="${stamp(new Date(Date.now() - 600000))} +0000" stop="${stamp(new Date(Date.now() + 3600000))} +0000"><title>Retired Stalker Bulletin</title></programme></tv>`,
|
||||
{
|
||||
contentType: 'application/xml',
|
||||
resourcePath: '/stalker.xml',
|
||||
}
|
||||
);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await app.mainWindow.route('https://test-streams.mux.dev/**', () => {
|
||||
// Keep external media pending while the local guide is exercised.
|
||||
});
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
name: 'Stalker XMLTV Removal',
|
||||
});
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
const playlistId = new URL(app.mainWindow.url()).pathname.match(
|
||||
/\/stalker\/([^/]+)/
|
||||
)?.[1];
|
||||
expect(playlistId).toBeTruthy();
|
||||
const key = `stalker:${decodeURIComponent(playlistId!)}:${String(item.id).trim()}`;
|
||||
await app.mainWindow.evaluate(
|
||||
(mappingKey) =>
|
||||
window.electron.setEpgMapping(mappingKey, 'stalker-mapped'),
|
||||
key
|
||||
);
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
await app.mainWindow
|
||||
.locator('.epg-source-row input')
|
||||
.fill(source.resourceUrl);
|
||||
await saveSettings(app.mainWindow);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
app.mainWindow.evaluate(async () =>
|
||||
(
|
||||
await window.electron.getChannelPrograms(
|
||||
'stalker-mapped'
|
||||
)
|
||||
).map((p) => p.title)
|
||||
),
|
||||
{ timeout: 30000 }
|
||||
)
|
||||
.toContain('Retired Stalker Bulletin');
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
const row = channelItemByTitle(
|
||||
app.mainWindow,
|
||||
item.o_name || item.name || ''
|
||||
).first();
|
||||
await expect(row.locator('.epg-title')).toHaveText(
|
||||
'Retired Stalker Bulletin'
|
||||
);
|
||||
await row.click();
|
||||
await expect
|
||||
.poll(() => timelineBlockTitles(app.mainWindow))
|
||||
.toContain('Retired Stalker Bulletin');
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow.locator('.epg-source-row button').nth(1).click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
await expect(row).toBeVisible();
|
||||
await row.click();
|
||||
await expect
|
||||
.poll(() => timelineBlockTitles(app.mainWindow))
|
||||
.not.toContain('Retired Stalker Bulletin');
|
||||
await expect(row.locator('.epg-title')).toHaveCount(0);
|
||||
expect(
|
||||
await app.mainWindow.evaluate(
|
||||
(mappingKey) => window.electron.getEpgMapping(mappingKey),
|
||||
key
|
||||
)
|
||||
).toMatchObject({ epgChannelId: 'stalker-mapped' });
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await source.close();
|
||||
}
|
||||
});
|
||||
|
||||
for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
||||
test(`@epg @xtream @electron renders Xtream EPG previews and the timeline schedule in ${timeZone}`, async ({
|
||||
dataDir,
|
||||
@@ -66,9 +250,7 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
||||
|
||||
// Provider-declared catch-up (tv_archive=1 in the fixture) is
|
||||
// surfaced as a badge on the sidebar row (#1128).
|
||||
await expect(
|
||||
channelRow.getByTestId('catchup-badge')
|
||||
).toBeVisible();
|
||||
await expect(channelRow.getByTestId('catchup-badge')).toBeVisible();
|
||||
|
||||
// Sidebar channel list shows the per-channel "now" programme line.
|
||||
await expect
|
||||
@@ -263,10 +445,7 @@ test('@radio @stalker @electron keeps radio rows compact at narrow widths', asyn
|
||||
await expect(categoryButton).toBeVisible();
|
||||
await categoryButton.click();
|
||||
|
||||
const radioRow = channelItemByTitle(
|
||||
app.mainWindow,
|
||||
firstTitle
|
||||
).first();
|
||||
const radioRow = channelItemByTitle(app.mainWindow, firstTitle).first();
|
||||
await expect(radioRow).toBeVisible({ timeout: 20000 });
|
||||
await expect(radioRow).toHaveClass(/compact/);
|
||||
await expect(radioRow.locator('.epg-placeholder')).toHaveCount(0);
|
||||
@@ -301,7 +480,8 @@ test('@epg @xtream @electron shifts the sidebar preview and the timeline "now" b
|
||||
// The guide runs an hour ahead of the real schedule, so the programme
|
||||
// actually on air is the one the provider files under "an hour ago" —
|
||||
// and every surface must agree on that same programme.
|
||||
const providerNowSeconds = Math.floor(Date.now() / 1000) - offsetMinutes * 60;
|
||||
const providerNowSeconds =
|
||||
Math.floor(Date.now() / 1000) - offsetMinutes * 60;
|
||||
const expectedProgram = fixture.fullEpg.find(
|
||||
(listing) =>
|
||||
listing.startTimestamp <= providerNowSeconds &&
|
||||
@@ -341,8 +521,7 @@ test('@epg @xtream @electron shifts the sidebar preview and the timeline "now" b
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(
|
||||
(await channelRow.locator('.epg-title').textContent()) ??
|
||||
''
|
||||
(await channelRow.locator('.epg-title').textContent()) ?? ''
|
||||
).trim()
|
||||
)
|
||||
.toBe(expectedProgram.title);
|
||||
|
||||
Reference in new issue
Block a user