mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
* feat(epg): accept local XMLTV files as EPG sources Settings → EPG and the playlist dialog accepted `file://` in their form pattern, but the main process rejected everything except http(s), so a local XMLTV entry saved fine and then failed on import. Both surfaces now take a remote link, a `file:` URL, an absolute POSIX path or a Windows drive/UNC path (`classifyEpgSourceReference` in shared/interfaces), and the settings section spells out the accepted formats with examples. The EPG worker opens every source through `openEpgSourceStream`: remote links keep the validated-redirect client and trust policy, local files are read from disk behind the signature-sniffing optional gunzip stage, so .xml, .xml.gz and extension-less gzip all parse. Only hand-typed sources may be local: `extractM3uEpgUrls` harvests http(s) links only from M3U headers, since the local branch bypasses `validateRemoteUrl`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(epg): pick local XMLTV files with a native file dialog A folder button beside each EPG source row (Settings → EPG and the playlist dialog) opens the native open-file dialog and writes the chosen absolute path into the row. New `EPG_OPEN_FILE_DIALOG` IPC behind `ElectronBridgeApi.openEpgFileDialog`, gated in the renderer by `RuntimeCapabilitiesService.supportsEpgFilePicker`. The row's refresh/remove buttons carry `data-test-id`s now, and the EPG e2e suites address them by id instead of index, since the folder button became the first button in a row. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): authorize local XMLTV files in the main process Review follow-up (Greptile P1, Codex P1). The renderer hands source strings to FETCH_EPG/EPG_FORCE_FETCH unchanged, so the form validator alone could not enforce the provenance rule: a compromised renderer, or a legacy `file://` entry an older version stored from an M3U header, could name any file on disk. `EpgWorkerService.startFetch` now asks a main-process `EpgLocalSourceAuthorizer` before a local path reaches the worker: a path the native picker returned is trusted at once, a hand-typed path is confirmed once in a native message box the renderer cannot fake, and a refusal is reported in the progress panel. Allowed paths persist under TRUSTED_LOCAL_EPG_SOURCES in the main-process config. The worker opens its local branch only when main set `allowLocalFile`; the service defaults to deny-all until epg.events installs the persisted authorizer. `resolvePlaylistEpgSourceState` and `filterPlaylistEpgUrlsForFetch` drop a stored non-remote entry unless it is also in `manualEpgUrls`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): fail a refused local EPG fetch instead of resolving it Review follow-up (Codex P2). A denied native confirmation now rejects the fetch after reporting the error row, so handleFetchEpg and the renderer's fetch result cannot claim the file was read. Also restores the unrelated CLAUDE.md paragraph an earlier formatter pass had reflowed into a list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): cancel a local source retired during its authorization prompt Review follow-up (Codex P2). startFetch keeps the request generation captured before awaiting the native confirmation and rechecks it afterwards: a source retired meanwhile ends as cancelled instead of starting an import that a pending clear would then have to await. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(epg): leave the local XMLTV e2e with a pristine settings form The local-file test ended with the EPG source field still dirty, which arms the main-process close guard: the app then waited for the unsaved changes dialog instead of closing, the close timeout killed it, and on Windows the killed process kept iptvnator.db busy (EBUSY on the data-dir cleanup) and hung the Playwright worker teardown. Discarding the form before the app closes takes the test from 15 s to 4 s locally. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
896 lines
35 KiB
TypeScript
896 lines
35 KiB
TypeScript
import { writeFileSync } from 'node:fs';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { createServer } from 'node:http';
|
|
import { join } from 'node:path';
|
|
import { gzipSync } from 'node:zlib';
|
|
import { Page } from '@playwright/test';
|
|
import {
|
|
buildM3uContent,
|
|
channelItemByTitle,
|
|
closeElectronApp,
|
|
createMutableTextServer,
|
|
expect,
|
|
goToDashboard,
|
|
importM3uPlaylistFromUrl,
|
|
launchElectronApp,
|
|
openWorkspaceSection,
|
|
openSettings,
|
|
openSettingsSection,
|
|
saveSettings,
|
|
test,
|
|
} from './electron-test-fixtures';
|
|
|
|
const fallbackLogoSvg = Buffer.from(
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><rect width="96" height="96" rx="20" fill="#16436b"/><text x="48" y="58" text-anchor="middle" font-size="28" font-family="Arial" fill="#f5f7fb">GN</text></svg>`
|
|
).toString('base64');
|
|
const fallbackLogoDataUrl = `data:image/svg+xml;base64,${fallbackLogoSvg}`;
|
|
const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<tv>
|
|
<channel id="guide-news">
|
|
<display-name>Guide News</display-name>
|
|
</channel>
|
|
<programme start="20260328070000 +0000" stop="20260328080000 +0000" channel="guide-news">
|
|
<title>Guide Bulletin</title>
|
|
<desc>EPG refresh smoke test.</desc>
|
|
</programme>
|
|
</tv>
|
|
`;
|
|
|
|
function createCurrentXmltvFixture(
|
|
channelId: string,
|
|
channelName: string,
|
|
programTitle: string,
|
|
iconUrl?: string
|
|
): string {
|
|
const start = new Date(Date.now() - 15 * 60 * 1000);
|
|
const stop = new Date(Date.now() + 45 * 60 * 1000);
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<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>
|
|
<desc>M3U-declared EPG source smoke test.</desc>
|
|
</programme>
|
|
</tv>
|
|
`;
|
|
}
|
|
|
|
function formatXmltvDate(date: Date): string {
|
|
const pad = (value: number) => String(value).padStart(2, '0');
|
|
|
|
return [
|
|
date.getUTCFullYear(),
|
|
pad(date.getUTCMonth() + 1),
|
|
pad(date.getUTCDate()),
|
|
pad(date.getUTCHours()),
|
|
pad(date.getUTCMinutes()),
|
|
pad(date.getUTCSeconds()),
|
|
].join('');
|
|
}
|
|
|
|
async function createGzipEpgServer(xml: string, doubleGzip: boolean) {
|
|
const file = gzipSync(xml);
|
|
const body = doubleGzip ? gzipSync(file) : file;
|
|
const server = createServer((_request, response) => {
|
|
response.writeHead(200, {
|
|
'Content-Type': 'application/gzip',
|
|
'Content-Encoding': 'gzip',
|
|
});
|
|
response.end(body);
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
const address = server.address();
|
|
if (!address || typeof address === 'string') {
|
|
throw new Error('Expected a TCP address for the XMLTV fixture server');
|
|
}
|
|
return {
|
|
url: `http://127.0.0.1:${address.port}/guide.xml.gz`,
|
|
close: () =>
|
|
new Promise<void>((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
}),
|
|
};
|
|
}
|
|
|
|
test.describe('Electron EPG', () => {
|
|
for (const [name, doubleGzip] of [
|
|
['double', true],
|
|
['single', false],
|
|
] as const) {
|
|
test(`@epg @electron imports ${name} gzip with a .gz URL and HTTP gzip`, async ({
|
|
dataDir,
|
|
}) => {
|
|
const server = await createGzipEpgServer(
|
|
createCurrentXmltvFixture(
|
|
'gzip-news',
|
|
'Gzip News',
|
|
'Gzip Bulletin'
|
|
),
|
|
doubleGzip
|
|
);
|
|
try {
|
|
const app = await launchElectronApp(dataDir);
|
|
try {
|
|
const result = await app.mainWindow.evaluate(
|
|
(url) => window.electron.forceFetchEpg(url),
|
|
server.url
|
|
);
|
|
expect(result.success).toBe(true);
|
|
expect(await getEpgChannelCount(app.mainWindow)).toBe(1);
|
|
const metadata = await app.mainWindow.evaluate(() =>
|
|
window.electron.getEpgChannelMetadata(['gzip-news'])
|
|
);
|
|
expect(metadata['gzip-news']).toMatchObject({
|
|
displayName: 'Gzip News',
|
|
});
|
|
const programs = await app.mainWindow.evaluate(() =>
|
|
window.electron.getChannelPrograms('gzip-news')
|
|
);
|
|
expect(programs.map((program) => program.title)).toEqual([
|
|
'Gzip Bulletin',
|
|
]);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
});
|
|
}
|
|
|
|
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.getByTestId('epg-source-remove').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 imports a local gzipped XMLTV file given as an absolute path', async ({
|
|
dataDir,
|
|
}) => {
|
|
const localGuide = join(dataDir, 'local guide.xml.gz');
|
|
writeFileSync(localGuide, gzipSync(epgFixtureXml));
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await openSettings(app.mainWindow);
|
|
await openSettingsSection(app.mainWindow, 'epg');
|
|
await expect(
|
|
app.mainWindow.getByTestId('epg-source-formats')
|
|
).toContainText('file://');
|
|
await app.mainWindow
|
|
.getByRole('button', { name: 'Add EPG source' })
|
|
.click();
|
|
const field = app.mainWindow
|
|
.locator('.epg-source-row input')
|
|
.first();
|
|
await field.fill('guide.xml');
|
|
// Material reveals errors once the control is touched.
|
|
await field.blur();
|
|
await expect(
|
|
app.mainWindow.locator('.epg-source-row mat-error')
|
|
).toBeVisible();
|
|
await field.fill(localGuide);
|
|
await expect(
|
|
app.mainWindow.locator('.epg-source-row mat-error')
|
|
).toHaveCount(0);
|
|
|
|
// Native dialogs cannot be driven from Playwright: stub them in
|
|
// the main process. A hand-typed path is confirmed once through
|
|
// the message box; a refused path is reported, never read.
|
|
await app.electronApp.evaluate(({ dialog }) => {
|
|
dialog.showMessageBox = (async () => ({
|
|
response: 1,
|
|
checkboxChecked: false,
|
|
})) as typeof dialog.showMessageBox;
|
|
});
|
|
await app.mainWindow.getByTestId('epg-source-refresh').click();
|
|
await expect(
|
|
app.mainWindow.locator(
|
|
'.epg-progress-panel .import-item.status-error'
|
|
)
|
|
).toContainText('was not allowed');
|
|
expect(await getEpgChannelCount(app.mainWindow)).toBe(0);
|
|
|
|
await app.electronApp.evaluate(({ dialog }) => {
|
|
dialog.showMessageBox = (async () => ({
|
|
response: 0,
|
|
checkboxChecked: false,
|
|
})) as typeof dialog.showMessageBox;
|
|
});
|
|
await app.mainWindow
|
|
.locator('.epg-progress-panel .retry-btn')
|
|
.click();
|
|
await expect
|
|
.poll(() => getEpgChannelCount(app.mainWindow), {
|
|
timeout: 30000,
|
|
})
|
|
.toBeGreaterThan(0);
|
|
await expect(
|
|
app.mainWindow.locator(
|
|
'.epg-progress-panel .import-item.status-complete'
|
|
)
|
|
).toHaveCount(1);
|
|
|
|
// A file chosen in the native picker is trusted without a prompt.
|
|
const browsedGuide = join(dataDir, 'browsed guide.xml.gz');
|
|
writeFileSync(browsedGuide, gzipSync(epgFixtureXml));
|
|
await app.electronApp.evaluate(({ dialog }, filePath) => {
|
|
dialog.showOpenDialog = (async () => ({
|
|
canceled: false,
|
|
filePaths: [filePath],
|
|
})) as typeof dialog.showOpenDialog;
|
|
dialog.showMessageBox = (async () => {
|
|
throw new Error('picked files must not prompt');
|
|
}) as typeof dialog.showMessageBox;
|
|
}, browsedGuide);
|
|
await app.mainWindow.getByTestId('epg-source-browse').click();
|
|
await expect(field).toHaveValue(browsedGuide);
|
|
await app.mainWindow.getByTestId('epg-source-refresh').click();
|
|
await expect(
|
|
app.mainWindow.locator(
|
|
'.epg-progress-panel .import-item.status-complete'
|
|
)
|
|
).toHaveCount(2);
|
|
|
|
// Leave with a pristine form: a dirty settings form arms the
|
|
// main-process close guard, and the app would then wait for a
|
|
// confirmation dialog instead of closing.
|
|
await app.mainWindow.getByTestId('discard-settings').click();
|
|
await expect(
|
|
app.mainWindow.getByTestId('settings-unsaved-bar')
|
|
).toHaveCount(0);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('@epg @electron adds an EPG source, fetches guide data, removes its stored EPG data on save', async ({
|
|
dataDir,
|
|
}) => {
|
|
const epgServer = await createMutableTextServer(epgFixtureXml, {
|
|
contentType: 'application/xml; charset=utf-8',
|
|
resourcePath: '/guide.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')
|
|
.first()
|
|
.fill(epgServer.resourceUrl);
|
|
|
|
await app.mainWindow.getByTestId('epg-source-refresh').click();
|
|
await expect(
|
|
app.mainWindow.locator('.epg-progress-panel')
|
|
).toBeVisible();
|
|
await expect
|
|
.poll(() => getEpgChannelCount(app.mainWindow), {
|
|
timeout: 30000,
|
|
})
|
|
.toBeGreaterThan(0);
|
|
await expect(
|
|
app.mainWindow
|
|
.locator('.epg-progress-panel .stat-badge')
|
|
.first()
|
|
).toBeVisible();
|
|
|
|
// The row must fit the 300px panel: Material icon buttons carry a
|
|
// 48px touch target that used to poke past the row padding and
|
|
// turn into a horizontal scrollbar.
|
|
const importsList = app.mainWindow.locator(
|
|
'.epg-progress-panel .imports-list'
|
|
);
|
|
await expect
|
|
.poll(() =>
|
|
importsList.evaluate(
|
|
(element) => element.scrollWidth - element.clientWidth
|
|
)
|
|
)
|
|
.toBe(0);
|
|
await expect(
|
|
app.mainWindow.locator('.epg-progress-panel .item-url')
|
|
).toHaveText(
|
|
`${new URL(epgServer.resourceUrl).hostname}/guide.xml`
|
|
);
|
|
|
|
await saveSettings(app.mainWindow);
|
|
|
|
await app.mainWindow.getByTestId('epg-source-remove').click();
|
|
await expect(app.mainWindow.locator('.epg-source-row')).toHaveCount(
|
|
0
|
|
);
|
|
// Adding and removing the source row leaves the settings form
|
|
// dirty; save so the unsaved-changes close guard has nothing to
|
|
// ask about when the app closes below — an unanswered dialog
|
|
// would block the close and leak the Electron process.
|
|
await saveSettings(app.mainWindow);
|
|
|
|
await expect
|
|
.poll(() => getEpgChannelCount(app.mainWindow), {
|
|
timeout: 20000,
|
|
})
|
|
.toBe(0);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
await epgServer.close();
|
|
}
|
|
});
|
|
|
|
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.getByTestId('epg-source-remove').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()
|
|
.getByTestId('epg-source-remove')
|
|
.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.getByTestId('epg-source-remove').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)
|
|
.getByTestId('epg-source-remove')
|
|
.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',
|
|
'Playlist Guide News',
|
|
'Playlist Scoped Bulletin'
|
|
),
|
|
{
|
|
contentType: 'application/xml; charset=utf-8',
|
|
resourcePath: '/guides/ua/playlist-guide.xml',
|
|
}
|
|
);
|
|
const detectedEpgUrls = [
|
|
`${epgServer.origin}/guides/us/ignored.xml`,
|
|
`${epgServer.origin}/guides/de/ignored.xml`,
|
|
epgServer.resourceUrl,
|
|
`${epgServer.origin}/guides/fr/ignored.xml`,
|
|
`${epgServer.origin}/guides/uk/ignored.xml`,
|
|
`${epgServer.origin}/guides/es/ignored.xml`,
|
|
];
|
|
const playlistContent = buildM3uContent([
|
|
{
|
|
name: 'Playlist Guide News',
|
|
tvgCountry: 'UA',
|
|
tvgId: 'playlist-guide-news',
|
|
url: 'https://example.com/live/playlist-guide-news.m3u8',
|
|
},
|
|
]).replace(
|
|
'#EXTM3U',
|
|
`#EXTM3U x-tvg-url="${detectedEpgUrls.join(',')}"`
|
|
);
|
|
const playlistServer = await createMutableTextServer(playlistContent, {
|
|
contentType: 'application/x-mpegurl; charset=utf-8',
|
|
resourcePath: '/playlist-with-epg.m3u',
|
|
});
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await importM3uPlaylistFromUrl(
|
|
app.mainWindow,
|
|
playlistServer.resourceUrl
|
|
);
|
|
|
|
await expect(
|
|
app.mainWindow.locator('.epg-progress-panel')
|
|
).toBeVisible();
|
|
await expect(
|
|
app.mainWindow.locator('.epg-progress-panel .import-item')
|
|
).toHaveCount(1);
|
|
await expect(
|
|
app.mainWindow.locator(
|
|
'.epg-progress-panel .import-item.status-complete'
|
|
)
|
|
).toHaveCount(1, { timeout: 30000 });
|
|
await expect
|
|
.poll(() => getEpgChannelCount(app.mainWindow), {
|
|
timeout: 30000,
|
|
})
|
|
.toBeGreaterThan(0);
|
|
|
|
await openWorkspaceSection(app.mainWindow, 'All channels');
|
|
|
|
const channelItem = channelItemByTitle(
|
|
app.mainWindow,
|
|
'Playlist Guide News'
|
|
);
|
|
await expect(channelItem).toBeVisible();
|
|
await expect(channelItem.locator('.epg-title')).toContainText(
|
|
'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.getByTestId('epg-source-remove').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();
|
|
await epgServer.close();
|
|
}
|
|
});
|
|
|
|
test('@epg @electron uses the XMLTV channel icon as a fallback when the playlist has no tvg-logo', async ({
|
|
dataDir,
|
|
}) => {
|
|
const playlistServer = await createMutableTextServer(
|
|
buildM3uContent([
|
|
{
|
|
name: 'Guide News Live',
|
|
tvgId: 'guide-news',
|
|
url: 'https://example.com/live/guide-news.m3u8',
|
|
},
|
|
]),
|
|
{
|
|
contentType: 'application/x-mpegurl; charset=utf-8',
|
|
resourcePath: '/playlist.m3u',
|
|
}
|
|
);
|
|
const epgServer = await createMutableTextServer(
|
|
`<?xml version="1.0" encoding="UTF-8"?>
|
|
<tv>
|
|
<channel id="guide-news">
|
|
<display-name>Guide News Live</display-name>
|
|
<icon src="${fallbackLogoDataUrl}"/>
|
|
</channel>
|
|
<programme start="20260328070000 +0000" stop="20260328080000 +0000" channel="guide-news">
|
|
<title>Guide Bulletin</title>
|
|
<desc>EPG logo fallback test.</desc>
|
|
</programme>
|
|
</tv>
|
|
`,
|
|
{
|
|
contentType: 'application/xml; charset=utf-8',
|
|
resourcePath: '/guide.xml',
|
|
}
|
|
);
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await importM3uPlaylistFromUrl(
|
|
app.mainWindow,
|
|
playlistServer.resourceUrl
|
|
);
|
|
|
|
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')
|
|
.first()
|
|
.fill(epgServer.resourceUrl);
|
|
await app.mainWindow.getByTestId('epg-source-refresh').click();
|
|
|
|
await expect
|
|
.poll(() => getEpgChannelCount(app.mainWindow), {
|
|
timeout: 30000,
|
|
})
|
|
.toBeGreaterThan(0);
|
|
|
|
// The added source is still only staged in the form — leaving
|
|
// settings without saving would surface the unsaved-changes
|
|
// dialog and block the navigation below.
|
|
await saveSettings(app.mainWindow);
|
|
|
|
await openWorkspaceSection(app.mainWindow, 'All channels');
|
|
|
|
const channelItem = channelItemByTitle(
|
|
app.mainWindow,
|
|
'Guide News Live'
|
|
);
|
|
await expect(channelItem).toBeVisible();
|
|
await expect(channelItem.locator('.channel-logo')).toHaveAttribute(
|
|
'src',
|
|
fallbackLogoDataUrl
|
|
);
|
|
await expect(
|
|
channelItem.locator('.channel-logo-fallback')
|
|
).toHaveCount(0);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
await playlistServer.close();
|
|
await epgServer.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
async function getEpgChannelCount(page: Page): Promise<number> {
|
|
return page.evaluate(async () => {
|
|
const result = await window.electron?.getEpgChannels?.();
|
|
return Array.isArray(result?.channels) ? result.channels.length : 0;
|
|
});
|
|
}
|