Files
iptvnator/apps/electron-backend-e2e/src/epg.e2e.ts
T
4grayandClaude Fable 5 1a6af75761 feat(settings): per-section pages with unsaved-changes bar (#1384)
* feat(settings): split settings into per-section pages with an unsaved-changes bar

Replace the single scrolling settings page with routed section pages
(/workspace/settings/:section): the context-panel rail links each section,
only the active section renders, and unknown or capability-gated sections
redirect to General. The shared form lives on the parent component, so
staged edits survive section switches; a floating unsaved-changes bar
(Save/Discard) replaces the always-visible footer Save button. Rail links
navigate with replaceUrl so Back still leaves settings in one step.

Along the way:
- delete the unreachable settings dialog mode and the dead
  AppPortalNavigationActionsService with both of its never-injected DI
  tokens (PORTAL_NAVIGATION_ACTIONS, PLAYLIST_PLAYER_ACTIONS)
- delete the scroll-spy directive and pendingScrollTarget plumbing
- revive the EPG panel's "Open EPG settings" empty-state button as a deep
  link to /workspace/settings/epg; the M3U player now reports
  m3u-needs-setup only when the channel has no programmes and no EPG
  source exists in settings or on the playlist itself
- load TMDB cache stats when the Metadata page opens (the section
  component now only exists while its page is open)
- add SETTINGS.UNSAVED_CHANGES / SETTINGS.DISCARD_CHANGES to all 19 locales

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(settings): confirm before leaving with unsaved changes

Add settingsUnsavedChangesGuard (canDeactivate on the :section route) with
a three-action dialog: save and leave, leave without saving, keep editing.
The guard only intercepts leaving the settings AREA — section switches
share the one settings form and pass unconditionally, so the dialog can
never nag while moving between pages. A failed save cancels the navigation
instead of silently dropping the edits it promised to keep; leaving
without saving also reverts the live theme preview. Save-and-leave is
disabled while the form is invalid, with a hint explaining why.

New SETTINGS.UNSAVED_DIALOG_* keys in all 19 locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): stage cover size and EPG view mode; adapt e2e to section pages

Cover size and EPG view mode were the only two controls that persisted
eagerly on click, which made Discard (and leave-without-saving) unable to
revert them: hydrateFromStore() faithfully reloaded the just-persisted
edit. They now stage in the form like every other setting and reach the
store on Save. Review finding by Greptile (P1) and Codex.

E2E suites that walk through settings are updated for one-section-page
rendering (epg, backup-roundtrip, xtream-epg, remote-control) and for the
staged cover size (downloads asserts the dataset after Save); the EPG icon
fallback test saves before leaving settings so the new unsaved-changes
dialog does not block its navigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 09:34:03 +02:00

312 lines
10 KiB
TypeScript

import { Page } from '@playwright/test';
import {
buildM3uContent,
channelItemByTitle,
closeElectronApp,
createMutableTextServer,
expect,
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
): 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>
</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('');
}
test.describe('Electron EPG', () => {
test('@epg @electron adds an EPG source, fetches guide data, removes the source row, and clears stored EPG data', 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
.locator('.epg-source-row button')
.first()
.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();
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await expect(app.mainWindow.locator('.epg-source-row')).toHaveCount(
0
);
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,
})
.toBe(0);
} finally {
await closeElectronApp(app);
await epgServer.close();
}
});
test('@epg @electron imports and renders an EPG source declared by an M3U playlist header', async ({
dataDir,
}) => {
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 }
);
} 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
.locator('.epg-source-row button')
.first()
.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 channels = await window.electron?.getEpgChannelsByRange?.(0, 20);
return Array.isArray(channels) ? channels.length : 0;
});
}