From 9feff291de36f5c20a622fbfe831e724854f2216 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 12 Oct 2026 09:04:35 +0200 Subject: [PATCH] test(m3u): cover hidden groups when the Groups route opens before the inventory An Electron E2E reloads the renderer on the Groups route while the startup inventory is held (the EPG reconcile fails once and its retry is parked), and records from document start whether a hidden group is ever rendered visibly. It fails on the previous hydration that did not wait for the playlist entity. A service test pins that getPlaylist reads hidden groups from a meta update that is still being written. Co-Authored-By: Claude Opus 5.5 --- .../src/m3u-group-management.e2e.ts | 230 ++++++++++++++++-- docs/architecture/m3u-playlist-module.md | 6 +- .../src/lib/playlists.service.spec.ts | 29 +++ 3 files changed, 239 insertions(+), 26 deletions(-) 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 };