Files
iptvnator/apps/electron-backend-e2e/src/playlist-auto-refresh.e2e.ts
T
4grayandClaude Opus 5 f852bc7459 fix(playlists): report failed playlists in the startup auto-refresh toast (#1235)
The startup auto-refresh always opened the
`HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_SUCCESS` snackbar, even when the backend
had dropped playlists it could not refresh. Isolating per-playlist failures
(#1233) means the result set is lossy by design, so an unreachable source that
now fails within `PLAYLIST_FETCH_TIMEOUT_MS` produces a false success toast.

`autoUpdatePlaylists()` now returns `AutoUpdatePlaylistsResult` — the refreshed
playlists plus one outcome per requested playlist (`updated` / `failed` /
`skipped`), in request order — on top of the existing bounded-concurrency
refresh. The renderer derives the message from those outcomes:

- all updated -> `AUTO_REFRESH_UPDATE_SUCCESS` (unchanged)
- some failed -> `AUTO_REFRESH_UPDATE_PARTIAL` (error styling, dismissable)
- some failed and some skipped -> `AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED`
- none updated -> `AUTO_REFRESH_UPDATE_FAILED` (error styling, dismissable)
- only sourceless playlists left over -> `AUTO_REFRESH_UPDATE_SKIPPED`

Playlists with neither a URL nor a file path are reported as skipped rather
than failed, since there is no source to refresh them from. The mixed
failed+skipped message exists because the plain partial text names only
updated/total/failed, which would leave the skipped playlists as an
unexplained remainder. Titles of unresolved playlists are logged for
diagnosability.

Tests: five new `electron.service` cases (one per message branch), outcome
assertions across the existing `playlist-auto-update` and `playlist.events`
specs, and an Electron E2E that restarts the app against a killed playlist
server — verified to fail against the old unconditional toast.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 11:33:07 +02:00

114 lines
4.1 KiB
TypeScript

import type { Locator, Page } from '@playwright/test';
import {
closeElectronApp,
createMutableTextServer,
expect,
importM3uPlaylistFromUrl,
launchElectronApp,
openSourceEditor,
openSources,
restartElectronApp,
saveSourceDialog,
sourceRowByTitle,
test,
updateSourceDialog,
waitForM3uCatalog,
} from './electron-test-fixtures';
const autoRefreshSourceName = 'auto-refresh-source.m3u';
function buildAutoRefreshM3u(channelName: string): string {
return `#EXTM3U
#EXTINF:-1 group-title="News",${channelName}
https://streams.example.test/${channelName.toLowerCase().replace(/\s+/g, '-')}.m3u8
`;
}
function snackBarLabel(page: Page, text: string): Locator {
return page
.locator('.mat-mdc-snack-bar-label')
.filter({ hasText: text })
.last();
}
async function openAutoRefreshPlaylistCatalog(page: Page): Promise<void> {
await openSources(page);
await sourceRowByTitle(page, autoRefreshSourceName).first().click();
await waitForM3uCatalog(page);
}
test.describe('Electron startup playlist auto-refresh', () => {
test('reports failed playlists instead of an unconditional success toast', async ({
dataDir,
}) => {
const urlServer = await createMutableTextServer(
buildAutoRefreshM3u('Original Auto Channel'),
{
contentType: 'application/vnd.apple.mpegurl',
resourcePath: `/${autoRefreshSourceName}`,
}
);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromUrl(
app.mainWindow,
urlServer.resourceUrl
);
await openSources(app.mainWindow);
const dialog = await openSourceEditor(
app.mainWindow,
autoRefreshSourceName
);
await updateSourceDialog(dialog, { autoRefresh: true });
await saveSourceDialog(app.mainWindow, dialog);
// Reachable source: the startup run really refreshes the playlist.
// Asserted through the persisted catalog rather than the 2s success
// toast, which is too short-lived to poll for reliably.
urlServer.setBody(buildAutoRefreshM3u('Refreshed Auto Channel'));
const successfulRestart = await restartElectronApp(app, dataDir);
app.electronApp = successfulRestart.electronApp;
app.mainWindow = successfulRestart.mainWindow;
await openAutoRefreshPlaylistCatalog(app.mainWindow);
await expect(
app.mainWindow.getByTestId('channel-item').filter({
hasText: 'Refreshed Auto Channel',
})
).toHaveCount(1);
// Unreachable source: the backend drops the playlist from its
// result, so the startup toast must say so instead of claiming
// everything was updated.
await urlServer.close();
const failedRestart = await restartElectronApp(app, dataDir);
app.electronApp = failedRestart.electronApp;
app.mainWindow = failedRestart.mainWindow;
await expect(
snackBarLabel(app.mainWindow, 'Playlist auto-refresh failed')
).toBeVisible({ timeout: 30000 });
await expect(
app.mainWindow.locator('.mat-mdc-snack-bar-label').filter({
hasText: 'The playlists were successfully updated',
})
).toHaveCount(0);
// A failed refresh must not wipe the previously imported channels.
await openAutoRefreshPlaylistCatalog(app.mainWindow);
await expect(
app.mainWindow.getByTestId('channel-item').filter({
hasText: 'Refreshed Auto Channel',
})
).toHaveCount(1);
} finally {
await closeElectronApp(app);
// The test closes the server mid-run to make the source
// unreachable; a second close rejects with ERR_SERVER_NOT_RUNNING.
await urlServer.close().catch(() => undefined);
}
});
});