diff --git a/apps/electron-backend-e2e/src/m3u-group-management.e2e.ts b/apps/electron-backend-e2e/src/m3u-group-management.e2e.ts index 0045d3905..1dfcf7f82 100644 --- a/apps/electron-backend-e2e/src/m3u-group-management.e2e.ts +++ b/apps/electron-backend-e2e/src/m3u-group-management.e2e.ts @@ -1,8 +1,12 @@ import type { Locator, Page } from '@playwright/test'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces'; import { closeElectronApp, + electronMainPath, expect, + type LaunchedElectronApp, importM3uPlaylistFromNativeDialog, launchElectronApp, openSources, @@ -13,8 +17,101 @@ import { waitForM3uCatalog, writeTemporaryM3uFile, } from './electron-test-fixtures'; +import { + expectRendererReloadedOnRoute, + reloadFromMainProcess, +} from './renderer-reload.support'; const SOURCE_TITLE = 'group-management-source'; +const HIDDEN_GROUPS = ['Movies', 'Sports']; +const VISIBLE_HIDDEN_GROUPS_KEY = '__e2eVisibleHiddenGroups'; +const RECONCILE_CHANNEL = 'EPG_RECONCILE_SOURCES'; + +interface ReconcileScript { + calls: number; + gate: Promise; + release: () => void; +} + +type ReconcileGlobal = typeof globalThis & { + __e2eReconcileScript?: ReconcileScript; +}; + +// Serialized into the test bootstrap; all runtime dependencies are arguments. +function installReconcileScript( + { ipcMain }: Pick, + channel: string +): void { + const register = ipcMain.handle; + const scope = globalThis as ReconcileGlobal; + ipcMain.handle = function (name, listener) { + if (name !== channel) { + return register.call(this, name, listener); + } + return register.call(this, name, async (...args) => { + const script = scope.__e2eReconcileScript; + if (script) { + script.calls += 1; + if (script.calls === 1) return { success: false }; + await script.gate; + } + return listener(...args); + }); + }; +} + +function launchWithReconcileScript( + dataDir: string +): Promise { + const entryPoint = join(dataDir, 'reconcile-script.cjs'); + writeFileSync( + entryPoint, + [ + `(${installReconcileScript.toString()})(require('electron'), ${JSON.stringify(RECONCILE_CHANNEL)});`, + `require(${JSON.stringify(electronMainPath)});`, + ].join('\n') + ); + return launchElectronApp(dataDir, { entryPoint }); +} + +/** + * The next renderer's startup reconciliation fails, and the retry the + * playlist inventory awaits is parked until `release`: the M3U route then + * activates (its settings resolver has finished) while the store still has no + * playlists. + */ +async function delayPlaylistInventory(app: LaunchedElectronApp) { + await app.electronApp.evaluate(() => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + (globalThis as ReconcileGlobal).__e2eReconcileScript = { + calls: 0, + gate, + release, + }; + }); + return { + waitUntilParked: () => + expect + .poll(() => + app.electronApp.evaluate( + () => + (globalThis as ReconcileGlobal).__e2eReconcileScript + ?.calls ?? 0 + ) + ) + .toBeGreaterThan(1), + release: () => + app.electronApp.evaluate(() => { + const scope = globalThis as ReconcileGlobal; + const script = scope.__e2eReconcileScript; + delete scope.__e2eReconcileScript; + script?.release(); + }), + }; +} const CHANNELS = ['News', 'Sports', 'Movies'].flatMap((group) => [1, 2].map((index) => ({ groupTitle: group, @@ -59,38 +156,82 @@ async function storedHiddenGroupTitles(page: Page): Promise { }); } +/** Imports the playlist and hides {@link HIDDEN_GROUPS} in Manage groups. */ +async function importAndHideGroups( + app: LaunchedElectronApp, + dataDir: string +): Promise { + const filePath = writeTemporaryM3uFile( + dataDir, + `${SOURCE_TITLE}.m3u`, + CHANNELS + ); + await importM3uPlaylistFromNativeDialog(app, filePath); + await waitForM3uCatalog(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Groups'); + await expect(groupNavLabels(app.mainWindow)).toHaveText([ + 'Movies', + 'News', + 'Sports', + ]); + + const dialog = await openGroupManagement(app.mainWindow); + for (const group of HIDDEN_GROUPS) { + await groupCheckbox(dialog, group).uncheck(); + } + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(dialog).toBeHidden(); + await expect(groupNavLabels(app.mainWindow)).toHaveText(['News']); + await expect + .poll(() => storedHiddenGroupTitles(app.mainWindow)) + .toEqual(HIDDEN_GROUPS); +} + +/** + * Records, from the start of every new document, each hidden group the + * groups rail ever renders visibly — including before the startup inventory + * publishes the playlist. + */ +async function recordVisibleHiddenGroups( + app: LaunchedElectronApp +): Promise { + await app.electronApp.context().addInitScript( + ({ hiddenGroups, key }) => { + const seen: string[] = []; + Reflect.set(window, key, seen); + const check = () => { + for (const item of document.querySelectorAll( + 'app-groups-view .group-nav-item' + )) { + const group = item.getAttribute('data-group-key') ?? ''; + if ( + hiddenGroups.includes(group) && + !seen.includes(group) && + item.checkVisibility() + ) { + seen.push(group); + } + } + }; + new MutationObserver(check).observe(document, { + attributes: true, + attributeFilter: ['class', 'style'], + childList: true, + subtree: true, + }); + }, + { hiddenGroups: HIDDEN_GROUPS, key: VISIBLE_HIDDEN_GROUPS_KEY } + ); +} + test.describe('Electron M3U group management', () => { test('keeps groups hidden in Manage groups after a restart (#1894)', async ({ dataDir, }) => { - const filePath = writeTemporaryM3uFile( - dataDir, - `${SOURCE_TITLE}.m3u`, - CHANNELS - ); const app = await launchElectronApp(dataDir); try { - await importM3uPlaylistFromNativeDialog(app, filePath); - await waitForM3uCatalog(app.mainWindow); - await openWorkspaceSection(app.mainWindow, 'Groups'); - await expect(groupNavLabels(app.mainWindow)).toHaveText([ - 'Movies', - 'News', - 'Sports', - ]); - - const dialog = await openGroupManagement(app.mainWindow); - await groupCheckbox(dialog, 'Movies').uncheck(); - await groupCheckbox(dialog, 'Sports').uncheck(); - await dialog - .getByRole('button', { name: 'Save', exact: true }) - .click(); - await expect(dialog).toBeHidden(); - await expect(groupNavLabels(app.mainWindow)).toHaveText(['News']); - await expect - .poll(() => storedHiddenGroupTitles(app.mainWindow)) - .toEqual(['Movies', 'Sports']); + await importAndHideGroups(app, dataDir); const restarted = await restartElectronApp(app, dataDir); app.electronApp = restarted.electronApp; @@ -118,4 +259,43 @@ test.describe('Electron M3U group management', () => { await closeElectronApp(app); } }); + + test('never shows hidden groups when the Groups route opens before the playlist inventory', async ({ + dataDir, + }) => { + const app = await launchWithReconcileScript(dataDir); + + try { + await importAndHideGroups(app, dataDir); + await recordVisibleHiddenGroups(app); + const inventory = await delayPlaylistInventory(app); + + // A reload boots a fresh store straight on this route. + await reloadFromMainProcess(app); + await inventory.waitUntilParked(); + // The route session has read the playlist and published its + // channels, still behind the startup screen, before the store + // has the playlist the hidden groups belong to. + const rail = app.mainWindow.locator( + 'app-groups-view .group-nav-item' + ); + await expect(rail).toHaveCount(3); + await expect(rail.first()).toBeHidden(); + + await inventory.release(); + await expectRendererReloadedOnRoute( + app.mainWindow, + /\/workspace\/playlists\/[^/]+\/groups$/ + ); + await expect(groupNavLabels(app.mainWindow)).toHaveText(['News']); + expect( + await app.mainWindow.evaluate( + (key) => Reflect.get(window, key), + VISIBLE_HIDDEN_GROUPS_KEY + ) + ).toEqual([]); + } finally { + await closeElectronApp(app); + } + }); }); diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index a291772e8..fa0e4d6de 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -641,7 +641,11 @@ channel-list-container/ A restored or deep-linked route can finish that read before the startup inventory, which waits for settings and EPG reconciliation, publishes the playlist; the hydration then waits for the entity, since `updateOne` drops an - update for a missing one. + update for a missing one. The startup screen keeps the router outlet hidden + until that inventory is published, and the hydration runs in the same store + update, so the rail never renders a hidden group. A save's meta write joins + the per-playlist queue that `getPlaylist()` reads through, so a reopened + route reads the saved value. - The groups route keeps the manage action reachable even when every group is hidden by separating "playlist has no groups" from "no visible/search-matching groups" empty states. diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index 7c44c6acf..f59e131a4 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -2005,6 +2005,35 @@ describe('PlaylistsService', () => { } ); + it('reads hidden groups saved by a meta update that is still being written', async () => { + const original = createBasePlaylist('catalog-hidden-groups'); + const { store, electron } = createStatefulElectronStore(original); + testWindow.electron = electron; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + electron.dbUpsertAppPlaylist.mockImplementationOnce( + async (playlist) => { + await gate; + store.current = playlist; + } + ); + const service = createService(); + // The meta effect subscribes while the save is dispatched, so the + // write is queued before the M3U route reopens the playlist. + const write = firstValueFrom( + service.updatePlaylistMeta({ + _id: original._id, + hiddenGroupTitles: ['Sports'], + } as PlaylistMeta) + ); + const read = firstValueFrom(service.getPlaylist(original._id)); + release(); + await write; + expect((await read).hiddenGroupTitles).toEqual(['Sports']); + }); + it('orders a browser catalog read after a queued IndexedDB refresh', async () => { const original = createBasePlaylist('browser-catalog'); const state = { current: original };