diff --git a/.changes/a11y-radio-play-focus-ring.md b/.changes/a11y-radio-play-focus-ring.md new file mode 100644 index 000000000..b74ddb129 --- /dev/null +++ b/.changes/a11y-radio-play-focus-ring.md @@ -0,0 +1,8 @@ +--- +type: fix +area: a11y +--- + +The radio player's play and pause button now shows the keyboard focus ring +when you tab to it, like every other control, and the app loads a little less +style code at startup. diff --git a/.changes/backup-pwa-xtream-user-state.md b/.changes/backup-pwa-xtream-user-state.md new file mode 100644 index 000000000..a2e8acec6 --- /dev/null +++ b/.changes/backup-pwa-xtream-user-state.md @@ -0,0 +1,6 @@ +--- +type: fix +area: backup +--- + +Backups made in the browser and self-hosted versions now include Xtream favorites, recently viewed items and resume positions. Restoring such a backup brings them back right away, under whichever playlist the portal is imported as. diff --git a/.changes/catalog-refine-menu-short-windows.md b/.changes/catalog-refine-menu-short-windows.md new file mode 100644 index 000000000..e1d5a8dcf --- /dev/null +++ b/.changes/catalog-refine-menu-short-windows.md @@ -0,0 +1,6 @@ +--- +type: fix +area: catalog +--- + +The Refine menu on Movies and Series now fits in a short window: the minimum-rating choices open in their own submenu, and the menu shows the active one. diff --git a/.changes/performance-lazy-form-field-defaults.md b/.changes/performance-lazy-form-field-defaults.md new file mode 100644 index 000000000..d1ae58790 --- /dev/null +++ b/.changes/performance-lazy-form-field-defaults.md @@ -0,0 +1,7 @@ +--- +type: perf +area: performance +--- + +The app loads less code at startup: the text field component and its form +helpers now load with the screens that show a form instead of with the app. diff --git a/.changes/performance-zoneless-change-detection.md b/.changes/performance-zoneless-change-detection.md new file mode 100644 index 000000000..85b46ab19 --- /dev/null +++ b/.changes/performance-zoneless-change-detection.md @@ -0,0 +1,8 @@ +--- +type: perf +area: performance +--- + +The app does less background work and starts with less code: screens now +update only when something they show changes, instead of after every timer, +network reply or click anywhere in the app. diff --git a/.changes/sources-hide-url-credentials.md b/.changes/sources-hide-url-credentials.md new file mode 100644 index 000000000..812a3c310 --- /dev/null +++ b/.changes/sources-hide-url-credentials.md @@ -0,0 +1,6 @@ +--- +type: fix +area: sources +--- + +Playlists added from a URL no longer show the login or password from that URL in the Sources list or the toolbar: they show the server's host instead. diff --git a/.changes/sources-redesigned-list-and-switcher.md b/.changes/sources-redesigned-list-and-switcher.md new file mode 100644 index 000000000..f79982aca --- /dev/null +++ b/.changes/sources-redesigned-list-and-switcher.md @@ -0,0 +1,7 @@ +--- +type: feature +area: sources +highlight: Redesigned Sources and playlist switcher +--- + +Sources and the playlist switcher share a new, compact row with a status dot. Filter Sources by type and by whether sources respond, delete with Undo, and open the switcher with ⌘P / Ctrl+P to search, filter by type and switch from the keyboard. diff --git a/.changes/stalker-series-discover-chips.md b/.changes/stalker-series-discover-chips.md new file mode 100644 index 000000000..db6945f23 --- /dev/null +++ b/.changes/stalker-series-discover-chips.md @@ -0,0 +1,6 @@ +--- +type: fix +area: stalker +--- + +Stalker series details now keep their clickable genre and country chips after TMDB fills in the details, so you can jump to Discover from them as you can for movies. diff --git a/.changes/ui-kit-global-layer.md b/.changes/ui-kit-global-layer.md new file mode 100644 index 000000000..cef96ffda --- /dev/null +++ b/.changes/ui-kit-global-layer.md @@ -0,0 +1,8 @@ +--- +type: fix +area: ui +--- + +Buttons that delete or discard data now show as a red tint with red text +instead of a solid red block, in both themes. The option lists of the Settings +dropdowns use the same compact, bordered panel as the app's menus. diff --git a/.changes/web-material-off-startup-path.md b/.changes/web-material-off-startup-path.md new file mode 100644 index 000000000..dd92c0114 --- /dev/null +++ b/.changes/web-material-off-startup-path.md @@ -0,0 +1,9 @@ +--- +type: perf +area: web +--- + +The app loads about 12% less code before it shows its first screen: dialog, +tooltip and progress-bar code, the animation engine and the theme colours of +unused components now load only when something needs them, so every launch +has less to download and parse. diff --git a/.changes/workspace-dashboard-return-swap.md b/.changes/workspace-dashboard-return-swap.md new file mode 100644 index 000000000..b8b06a04d --- /dev/null +++ b/.changes/workspace-dashboard-return-swap.md @@ -0,0 +1,8 @@ +--- +type: fix +area: workspace +--- + +Going back to the dashboard from a portal section no longer makes the page +jump: the dashboard replaces the portal page in one step instead of appearing +below it for a moment. diff --git a/.changes/workspace-side-panel-no-divider.md b/.changes/workspace-side-panel-no-divider.md new file mode 100644 index 000000000..dd711bff4 --- /dev/null +++ b/.changes/workspace-side-panel-no-divider.md @@ -0,0 +1,6 @@ +--- +type: fix +area: workspace +--- + +The side panel next to Sources, categories and Settings no longer draws a thin divider line against the content area, whose colour, shadow and rounded corner already mark the edge. Dragging the panel's edge still resizes it. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 965668988..6b4853bf0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,10 +1,11 @@ # Every Dependabot PR triggers the full pipeline (~15 jobs), so version # updates are batched weekly. Nx minor+patch updates use a dedicated group so # official packages move together; Nx majors are manual coordinated migrations. -# Native packaging, parser, and version-locked playback dependencies stay in -# standalone PRs; other minor+patch updates are grouped per ecosystem. Security -# updates are separate and are not limited by this schedule; CI enforces complete -# Nx lockstep. +# Electron majors are held while Linux armv7l packages ship: Electron 44 +# dropped the linux-armv7l binary. Native packaging, parser, and +# version-locked playback dependencies stay in standalone PRs; other +# minor+patch updates are grouped per ecosystem. Security updates are separate +# and are not limited by this schedule; CI enforces complete Nx lockstep. version: 2 updates: - package-ecosystem: npm @@ -21,6 +22,9 @@ updates: - dependency-name: '@nx/*' update-types: - version-update:semver-major + - dependency-name: electron + update-types: + - version-update:semver-major groups: nx-version-updates: applies-to: version-updates diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b510ba072..0405203ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -348,9 +348,12 @@ jobs: --only launch/main.modulesRegisteredBeforeWindow --only launch/renderer.layoutShiftScore --only launch/renderer.layoutShiftScoreSettled + --only launch/renderer.cdTicksToFirstCard + --only launch/renderer.cdTicksIdle30s --only open-source/main.mockHttpRequestsToSettled --only open-source/renderer.ipcCallsToFirstPage --only open-source/renderer.layoutShiftScore + --only open-source/renderer.cdTicksToFirstPage --only playback/renderer.domMutationsToPlaying --only playback/renderer.httpRequestsToPlaying --only playback/renderer.layoutShiftScore @@ -431,6 +434,17 @@ jobs: - name: Validate icon-only button names run: pnpm run a11y:icon-buttons:validate + # Form-field defaults are provided per lazy component: the root + # token import would load Material form-field and @angular/forms + # on startup (tools/nx/check-form-field-defaults.mjs). + - name: Validate form-field defaults + run: pnpm run ui:form-field-defaults:validate + + # m3-theme.scss themes only the Material components the app + # imports; a new import without a theme entry renders unthemed. + - name: Validate the Material theme scope + run: pnpm run styles:material-theme-scope:validate + # Nx rejects a non-parallel task with continuous dependencies, and # the Playwright plugin infers serve dependencies only when CI is # unset, so this checks both the local and the CI inference. diff --git a/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts b/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts index ff90069c5..80f0ac019 100644 --- a/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts +++ b/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts @@ -28,6 +28,9 @@ import { applyTheme, expectTextContrast } from './theme-contrast'; // Single-choice menus (catalog refine, live channels, categories, sources) // are radio groups: every row reserves the same leading check slot, so the // labels share one left edge, and `aria-checked` marks the active choice. +// +// The refine menu keeps its rating thresholds in a submenu, so it opens +// whole below its button in a short window. // --------------------------------------------------------------------------- const locales = ['en', 'de', 'ru', 'hu'] as const; @@ -83,14 +86,62 @@ function openMenuPanel(page: Page): Locator { return page.locator('.cdk-overlay-pane .mat-mdc-menu-panel').last(); } +/** The refine menu's own panel, also while its rating submenu is open. */ +function refineMenuPanel(page: Page): Locator { + return page.locator( + '.cdk-overlay-pane .mat-mdc-menu-panel.refine-menu:not(.refine-rating-menu)' + ); +} + +function ratingSubmenuPanel(page: Page): Locator { + return page.locator('.cdk-overlay-pane .refine-rating-menu'); +} + async function openRefineMenu(page: Page): Promise { await page.locator('app-category-content-view .refine-action').click(); - const menu = openMenuPanel(page); + const menu = refineMenuPanel(page); await expect(menu).toBeVisible(); await settleAnimations(menu); return menu; } +/** Opens the minimum-rating thresholds from the open refine menu. */ +async function openRatingSubmenu(page: Page): Promise { + await page.locator('.refine-rating-trigger').click(); + const submenu = ratingSubmenuPanel(page); + await expect(submenu).toBeVisible(); + await settleAnimations(submenu); + return submenu; +} + +/** The panel lies inside the window and shows every row without scrolling. */ +async function expectWholeOnScreen( + panel: Locator, + label: string +): Promise<{ top: number; bottom: number }> { + const geometry = await panel.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + top: rect.top, + bottom: rect.bottom, + windowHeight: window.innerHeight, + scrolls: element.scrollHeight > element.clientHeight + 1, + }; + }); + expect( + geometry.top, + `${label}: ${JSON.stringify(geometry)}` + ).toBeGreaterThanOrEqual(0); + expect( + geometry.bottom, + `${label}: ${JSON.stringify(geometry)}` + ).toBeLessThanOrEqual(geometry.windowHeight); + expect(geometry.scrolls, `${label}: ${JSON.stringify(geometry)}`).toBe( + false + ); + return geometry; +} + async function settleAnimations(menu: Locator): Promise { await menu.evaluate((element) => Promise.all( @@ -210,12 +261,8 @@ test.describe('Electron catalog sort menus', () => { // A rating threshold shows the second chip next to the sort one. let menu = await openRefineMenu(page); - await menu - .getByRole('group') - .nth(1) - .getByRole('menuitemradio') - .nth(5) - .click(); + const thresholds = await openRatingSubmenu(page); + await thresholds.getByRole('menuitemradio').nth(5).click(); await expect(menu).toBeHidden(); for (const locale of locales) { @@ -252,16 +299,33 @@ test.describe('Electron catalog sort menus', () => { menu = await openRefineMenu(page); const groups = menu.getByRole('group'); - await expect(groups).toHaveCount(2); + await expect(groups).toHaveCount(1); await expectRadioGroup(groups.nth(0), 5, `${locale} sort`); - await expectRadioGroup(groups.nth(1), 5, `${locale} rating`); - const lefts = (await radioRows(menu)).map( - (row) => row.labelLeft - ); + // The thresholds row starts its label on the sort labels' + // edge and names the active threshold. + const ratingRow = menu.locator('.refine-rating-trigger'); + await expect(ratingRow).toContainText('5.0+'); + const lefts = [ + ...(await radioRows(menu)).map((row) => row.labelLeft), + Math.round( + await ratingRow + .locator('.mat-mdc-menu-item-text') + .evaluate( + (text) => text.getBoundingClientRect().left + ) + ), + ]; expect( [...new Set(lefts)], - `${locale}: both groups` + `${locale}: sort and rating rows` ).toHaveLength(1); + const ratings = await openRatingSubmenu(page); + await expectRadioGroup( + ratings.getByRole('group'), + 5, + `${locale} rating` + ); + await closeMenu(page, ratings); await closeMenu(page, menu); } } finally { @@ -269,6 +333,63 @@ test.describe('Electron catalog sort menus', () => { } }); + test('opens the refine menu whole below its button in a 600px-tall window, with the rating thresholds in a keyboard submenu', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + + try { + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + await page.setViewportSize({ width: 1280, height: 600 }); + await openWorkspaceSection(page, 'Movies'); + await expect( + page.locator('app-category-content-view mat-card').first() + ).toBeVisible({ timeout: 20_000 }); + + const trigger = page.locator( + 'app-category-content-view .refine-action' + ); + await trigger.focus(); + await page.keyboard.press('Enter'); + const menu = refineMenuPanel(page); + await expect(menu).toBeVisible(); + await settleAnimations(menu); + const triggerBottom = await trigger.evaluate( + (element) => element.getBoundingClientRect().bottom + ); + const menuBox = await expectWholeOnScreen(menu, 'refine menu'); + // Below the button, not pushed up over it. + expect(menuBox.top).toBeGreaterThanOrEqual(triggerBottom); + + // The last row opens the thresholds with the arrow key. + await page.keyboard.press('ArrowUp'); + await expect(menu.locator('.refine-rating-trigger')).toBeFocused(); + await page.keyboard.press('ArrowRight'); + const thresholds = ratingSubmenuPanel(page); + await expect(thresholds).toBeVisible(); + await settleAnimations(thresholds); + await expect(thresholds.getByRole('menuitemradio')).toHaveCount(6); + await expect( + thresholds.getByRole('menuitemradio').first() + ).toBeFocused(); + await expectWholeOnScreen(thresholds, 'rating submenu'); + + // Escape steps back out: the submenu, then the menu. + await page.keyboard.press('Escape'); + await expect(thresholds).toBeHidden(); + await expect(menu.locator('.refine-rating-trigger')).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(menu).toBeHidden(); + await expect(trigger).toBeFocused(); + } finally { + await closeElectronApp(app); + } + }); + test('shows aligned radio rows with a legible check in the live, category and source sort menus, in light and dark themes', async ({ dataDir, request, diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index d2086dca9..1a490371b 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -545,8 +545,15 @@ async function expectCategoryCentered(row: Locator): Promise { } async function refreshFromWorkspaceHeader(page: Page): Promise { + // Refresh for the active source lives in its switcher row menu. + await page.getByTestId('playlist-switcher-trigger').click(); await page - .getByRole('button', { name: 'Refresh playlist', exact: true }) + .locator( + 'app-playlist-switcher-panel [role="option"][aria-current="true"] .source-row__more' + ) + .click(); + await page + .getByRole('menuitem', { name: 'Refresh playlist', exact: true }) .click(); const dialog = page.locator('mat-dialog-container'); diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index bc8f0e6d1..bef0b4f6e 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -1073,7 +1073,10 @@ export async function importM3uPlaylistFromUrl( playlistUrl ); if (userAgent !== undefined) { - await setInputValue(dialog.getByRole('textbox', { name: 'User agent', exact: true }), userAgent); + await setInputValue( + dialog.getByRole('textbox', { name: 'User agent', exact: true }), + userAgent + ); } await dialog.getByRole('button', { name: /Add playlist/i }).click(); await dialog.waitFor({ state: 'detached' }); @@ -1187,7 +1190,10 @@ export async function createMutableTextServer( return; } - if (options.requiredUserAgent && req.headers['user-agent'] !== options.requiredUserAgent) { + if ( + options.requiredUserAgent && + req.headers['user-agent'] !== options.requiredUserAgent + ) { res.writeHead(403); res.end('User-Agent required'); return; @@ -1469,20 +1475,23 @@ export async function clickCategoryByNameExact( } export function sourceRowByTitle(page: Page, title: string): Locator { - return page.locator('app-playlist-item').filter({ + return page.locator('app-workspace-sources app-source-row').filter({ hasText: flexibleTextPattern(title), }); } export async function getVisibleSourceTitles(page: Page): Promise { - return page.locator('app-playlist-item').evaluateAll((elements) => - elements - .map((element) => { - const titleElement = element.querySelector('.playlist-title'); - return titleElement?.textContent?.trim() ?? ''; - }) - .filter((title) => title.length > 0) - ); + return page + .locator('app-workspace-sources app-source-row') + .evaluateAll((elements) => + elements + .map((element) => { + const titleElement = + element.querySelector('.source-row__name'); + return titleElement?.textContent?.trim() ?? ''; + }) + .filter((title) => title.length > 0) + ); } export async function selectSourceTypeFilter( @@ -1497,8 +1506,10 @@ export async function selectSourceSort( sortLabel: | 'Date added (Newest first)' | 'Date added (Oldest first)' + | 'Last updated' | 'Name (A-Z)' | 'Name (Z-A)' + | 'Most channels' | 'Custom order' ): Promise { const sortTrigger = page.locator('app-workspace-sources .sort-trigger'); @@ -1523,10 +1534,10 @@ export async function dragSourceBefore( sourceTitle: string, targetTitle: string ): Promise { - const source = sourceRowByTitle(page, sourceTitle).locator('.drag-icon'); - const target = sourceRowByTitle(page, targetTitle).locator( - '.playlist-item' + const source = sourceRowByTitle(page, sourceTitle).locator( + '.source-row__handle' ); + const target = sourceRowByTitle(page, targetTitle).locator('.source-row'); await expect(source.first()).toBeVisible(); await expect(target.first()).toBeVisible(); @@ -1560,15 +1571,18 @@ export async function openSourceEditor( const row = sourceRowByTitle(page, title).first(); await expect(row).toBeVisible(); - await row.locator('.edit-btn').click(); + await row.hover(); + await row.locator('.source-row__edit').click(); const dialog = page.locator('mat-dialog-container').last(); await expect(dialog).toBeVisible(); // Material applies its initial focus after the opening transition. await dialog.evaluate(async (element) => { - await Promise.all(element.getAnimations({ subtree: true }).map((animation) => - animation.finished.catch(() => undefined) - )); + await Promise.all( + element + .getAnimations({ subtree: true }) + .map((animation) => animation.finished.catch(() => undefined)) + ); }); return dialog; } @@ -1678,12 +1692,24 @@ export async function expectSourceDialogValues( } } +/** + * Deletes a source from its ⋯ menu. The row leaves at once; the delete + * itself runs when the Undo toast closes, so this waits for the toast to go + * before the caller checks persisted state. + */ export async function deleteSource(page: Page, title: string): Promise { const row = sourceRowByTitle(page, title).first(); await expect(row).toBeVisible(); - await row.locator('.delete-btn').click(); - await confirmDialog(page, 'Remove playlist'); + await row.hover(); + await row.locator('.source-row__more').click(); + await page + .locator('.cdk-overlay-pane [mat-menu-item]') + .filter({ hasText: 'Remove playlist' }) + .click(); + const toast = page.locator('mat-snack-bar-container.app-toast'); + await expect(toast).toContainText('Removed'); + await expect(toast).toBeHidden({ timeout: 15000 }); } export async function refreshSource( @@ -1697,7 +1723,8 @@ export async function refreshSource( const row = sourceRowByTitle(page, title).first(); await expect(row).toBeVisible(); - await row.locator('.refresh-btn').click(); + await row.hover(); + await row.locator('.source-row__refresh').click(); if (confirm) { await confirmDialog(page, 'Refresh playlist'); @@ -1711,10 +1738,10 @@ export async function waitForSourceRowIdle( const row = sourceRowByTitle(page, title).first(); await expect(row).toBeVisible(); - await expect(row.locator('.busy-state__message')).toHaveCount(0, { + await expect(row.locator('.source-row__busy')).toHaveCount(0, { timeout: 60000, }); - await expect(row.locator('.action-spinner')).toHaveCount(0, { + await expect(row.locator('mat-spinner')).toHaveCount(0, { timeout: 60000, }); } diff --git a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts index e50145ea3..8b1fc7488 100644 --- a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts +++ b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts @@ -331,6 +331,7 @@ test('@epg @xtream @electron stacks the programme dialog actions on a phone', as ) as { WORKSPACE: { SHELL: { RAIL_LIVE: string } }; EPG: { + PREVIOUS_DAY: string; PROGRAM_DIALOG: { SHOW_PROGRAM_DETAILS: string }; TIMELINE: { WATCH_FROM_START: string }; }; @@ -365,6 +366,15 @@ test('@epg @xtream @electron stacks the programme dialog actions on a phone', as .first() .click(); const guide = app.mainWindow.locator('app-epg-list-view'); + await expect(guide.locator('[data-when="now"]').first()).toBeVisible(); + // The mock's 30-minute slots run relative to now, so in the first half + // hour after UTC midnight today holds only the programme on air: step + // back a day for a finished one. + if ((await guide.locator('[data-when="past"]').count()) === 0) { + await guide + .getByRole('button', { name: fr.EPG.PREVIOUS_DAY }) + .click(); + } await expect(guide.locator('[data-when="past"]').first()).toBeVisible(); // The window cannot shrink below its desktop minimum, so emulate a diff --git a/apps/electron-backend-e2e/src/file-import.e2e.ts b/apps/electron-backend-e2e/src/file-import.e2e.ts index 22f264e56..1aab6dc95 100644 --- a/apps/electron-backend-e2e/src/file-import.e2e.ts +++ b/apps/electron-backend-e2e/src/file-import.e2e.ts @@ -70,7 +70,7 @@ test.describe('Electron Native Playlist Import', () => { }); await openSources(app.mainWindow); await expect( - app.mainWindow.locator('app-playlist-item') + app.mainWindow.locator('app-workspace-sources app-source-row') ).toHaveCount(2, { timeout: 30000 }); } finally { await closeElectronApp(app); diff --git a/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts b/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts index 80ff98a98..59de70ae8 100644 --- a/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts +++ b/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts @@ -435,6 +435,7 @@ async function openMoviesWithRatingFilter(page: Page): Promise { timeout: 20_000, }); await view.locator('.refine-action').click(); + await page.locator('.refine-rating-trigger').click(); await page .getByRole('menuitemradio', { name: /^\s*5\.0 and higher/ }) .click(); @@ -550,19 +551,21 @@ test.describe('Keyboard focus ring', () => { // Sources: the type filters, then the list's row actions. await openSources(page); await expect( - page.locator('app-playlist-item').first() + page.locator('app-workspace-sources app-source-row').first() ).toBeVisible(); + // Add, the Type and Status rows, Clean up, Sort, then the first + // row and its actions; Check again lives in the row's menu. await walk( 'sources list', page.locator('app-workspace-shell'), page .locator('app-workspace-sources-filters-panel button') .first(), - 12 + 16 ); expect( report['sources list (dark)'].map((stop) => stop.label) - ).toContain('Check again'); + ).toContain('Playlist actions'); // The live EPG list: its rows are Tab stops (`role="button"`) // with their own inset ring, then the row's info button. diff --git a/apps/electron-backend-e2e/src/material-motion.e2e.ts b/apps/electron-backend-e2e/src/material-motion.e2e.ts new file mode 100644 index 000000000..4adc52cd0 --- /dev/null +++ b/apps/electron-backend-e2e/src/material-motion.e2e.ts @@ -0,0 +1,267 @@ +import type { Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + expect, + launchElectronApp, + openSettings, + openSettingsSection, + openSources, + test, +} from './electron-test-fixtures'; +import { + expectRendererReloadedOnRoute, + reloadFromRenderer, +} from './renderer-reload.support'; + +// --------------------------------------------------------------------------- +// app.config.ts does not provide the Angular animations module: nothing +// declares an animation trigger, and Material 22 animates its overlays with +// CSS, reading prefers-reduced-motion itself. These checks keep the overlays' +// enter and exit motion, and the close paths that wait for it, working +// without the module, and off under reduced motion. +// --------------------------------------------------------------------------- + +interface MotionEvent { + type: string; + name: string; + /** The class attribute of the element the event fired on. */ + target: string; +} + +type MotionWindow = Window & { __motionEvents?: MotionEvent[] }; + +/** Records every CSS animation and transition event of the document. */ +async function recordMotion(page: Page): Promise { + await page.evaluate(() => { + const events: MotionEvent[] = []; + (window as MotionWindow).__motionEvents = events; + for (const type of [ + 'animationstart', + 'animationend', + 'transitionrun', + 'transitionend', + ]) { + document.addEventListener( + type, + (event) => { + const target = event.target; + events.push({ + type, + name: + (event as AnimationEvent).animationName ?? + (event as TransitionEvent).propertyName, + target: + target instanceof Element + ? (target.getAttribute('class') ?? '') + : '', + }); + }, + true + ); + } + }); +} + +async function motionEvents(page: Page): Promise { + return page.evaluate(() => (window as MotionWindow).__motionEvents ?? []); +} + +/** + * Waits for an event of that type and name, on an element wearing + * `targetClass` when given, among the events recorded from `since` on. + */ +async function expectMotion( + page: Page, + type: string, + name: string, + { targetClass, since = 0 }: { targetClass?: string; since?: number } = {} +): Promise { + await expect + .poll( + async () => + (await motionEvents(page)) + .slice(since) + .some( + (event) => + event.type === type && + event.name === name && + (targetClass === undefined || + event.target.split(/\s+/).includes(targetClass)) + ), + `${type} ${name}${targetClass ? ` on .${targetClass}` : ''}` + ) + .toBe(true); +} + +async function recordedCount(page: Page): Promise { + return (await motionEvents(page)).length; +} + +/** Opens the Clear EPG data confirmation from the EPG settings section. */ +async function openClearEpgDialog(page: Page) { + await openSettingsSection(page, 'epg'); + await page + .getByRole('button', { name: 'Clear EPG data', exact: true }) + .click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + return dialog; +} + +test.describe('Material motion without the animations module', () => { + test('@theme @electron overlays animate in and out and close after their exit motion', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + try { + await addXtreamPortal(page); + await openSources(page); + await recordMotion(page); + + // Tooltip: keyframes on show and on hide. + const settingsLink = page.locator('a[href$="/workspace/settings"]'); + await settingsLink.hover(); + // Scoped to the one tooltip: the rail link hovered on the way + // to Sources may still be fading out. + const tooltip = page.locator('.mat-mdc-tooltip', { + hasText: 'Settings', + }); + await expect(tooltip).toBeVisible(); + await expect(tooltip).not.toHaveClass(/_mat-animation-noopable/); + await expectMotion(page, 'animationstart', 'mat-mdc-tooltip-show'); + await expectMotion(page, 'animationend', 'mat-mdc-tooltip-show'); + await page.mouse.move(640, 360); + await expectMotion(page, 'animationstart', 'mat-mdc-tooltip-hide'); + await expect(tooltip).toHaveCount(0); + + // Menu: the sources sort menu. + await page.locator('.sort-trigger').click(); + const menu = page.locator('.mat-mdc-menu-panel'); + await expect(menu).toBeVisible(); + await expect(menu).not.toHaveClass( + /mat-menu-panel-animations-disabled/ + ); + await expectMotion(page, 'animationstart', '_mat-menu-enter'); + // Dismissed only once the enter animation is over: a swap while + // it still runs cancels it, and Material treats the cancelled + // enter as the exit's end and detaches the panel at once. + await expectMotion(page, 'animationend', '_mat-menu-enter'); + await page.keyboard.press('Escape'); + await expectMotion(page, 'animationstart', '_mat-menu-exit'); + await expect(menu).toHaveCount(0); + + // Select: the language list, opened and dismissed unchanged. + await openSettings(page); + await openSettingsSection(page, 'general'); + await page.getByTestId('select-language').click(); + const panel = page.locator('.mat-mdc-select-panel'); + await expect(panel).toBeVisible(); + await expect(panel).toHaveClass( + /mat-select-panel-animations-enabled/ + ); + await expectMotion(page, 'animationstart', '_mat-select-enter'); + await expectMotion(page, 'animationend', '_mat-select-enter'); + await page.keyboard.press('Escape'); + await expectMotion(page, 'animationstart', '_mat-select-exit'); + await expect(panel).toHaveCount(0); + + // Dialog: Material drives it with transitions whose duration it + // writes on the container; the surface scales in. Confirming + // closes it after the exit transition and shows a snack-bar. + const beforeDialog = await recordedCount(page); + const dialog = await openClearEpgDialog(page); + await expect(dialog).not.toHaveClass(/_mat-animation-noopable/); + await expect(dialog).toHaveClass(/mdc-dialog--open/); + expect( + await dialog.evaluate((el) => + el.style.getPropertyValue( + '--mat-dialog-transition-duration' + ) + ) + ).toMatch(/^[1-9]\d*ms$/); + // The surface scales in while opening. + await expectMotion(page, 'transitionend', 'transform', { + targetClass: 'mat-mdc-dialog-surface', + since: beforeDialog, + }); + const beforeClose = await recordedCount(page); + await dialog.getByTestId('confirm-dialog-confirm').click(); + // Closing fades the inner container out. Material detaches the + // container on a timer of the same length, so the fade's end + // may never be observed; its start is, in the first frame. + await expectMotion(page, 'transitionrun', 'opacity', { + targetClass: 'mat-mdc-dialog-inner-container', + since: beforeClose, + }); + await expect(dialog).toHaveCount(0); + + const snackBar = page.locator('.mat-mdc-snack-bar-container'); + await expect(snackBar).toBeVisible(); + await expect(snackBar).toHaveClass( + /mat-snack-bar-container-animations-enabled/ + ); + await expectMotion(page, 'animationstart', '_mat-snack-bar-enter'); + // The confirmation toast lasts 2s, then leaves through its exit + // animation; the container is removed when that one ends. + await expectMotion(page, 'animationstart', '_mat-snack-bar-exit'); + await expect(snackBar).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + + test('@theme @electron reduced motion turns the overlay motion off', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + try { + // Material reads the preference once per document, so a fresh + // document must start under the emulated media. + await page.emulateMedia({ reducedMotion: 'reduce' }); + await reloadFromRenderer(app); + await expectRendererReloadedOnRoute(page, /\/workspace/); + await openSettings(page); + await openSettingsSection(page, 'general'); + await recordMotion(page); + + await page.getByTestId('select-language').click(); + const panel = page.locator('.mat-mdc-select-panel'); + await expect(panel).toBeVisible(); + await expect(panel).not.toHaveClass( + /mat-select-panel-animations-enabled/ + ); + await page.keyboard.press('Escape'); + await expect(panel).toHaveCount(0); + + const dialog = await openClearEpgDialog(page); + await expect(dialog).toHaveClass(/_mat-animation-noopable/); + // Material writes the transition duration only when it animates. + expect( + await dialog.evaluate((el) => + el.style.getPropertyValue( + '--mat-dialog-transition-duration' + ) + ) + ).toBe(''); + await dialog.getByTestId('confirm-dialog-confirm').click(); + await expect(dialog).toHaveCount(0); + + const snackBar = page.locator('.mat-mdc-snack-bar-container'); + await expect(snackBar).toBeVisible(); + await expect(snackBar).not.toHaveClass( + /mat-snack-bar-container-animations-enabled/ + ); + await expect(snackBar).toHaveCount(0, { timeout: 10_000 }); + + const names = (await motionEvents(page)) + .filter((event) => event.type === 'animationstart') + .map((event) => event.name); + expect(names.filter((name) => /^_mat-/.test(name))).toEqual([]); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/menu-keyboard.ts b/apps/electron-backend-e2e/src/menu-keyboard.ts new file mode 100644 index 000000000..3b42d2e62 --- /dev/null +++ b/apps/electron-backend-e2e/src/menu-keyboard.ts @@ -0,0 +1,21 @@ +import { expect, Locator, Page } from '@playwright/test'; + +/** + * Wait until keyboard focus has entered the open `mat-menu`, and return it. + * + * Material moves focus to the first item after the next render, which the + * zoneless scheduler runs on a later task than the keydown that opened the + * menu. The panel is visible before that, so a navigation key sent as soon + * as it appears still reaches the trigger and the menu never sees it. + */ +export async function waitForMenuFocus(page: Page): Promise { + const menu = page.getByRole('menu'); + await expect(menu).toBeVisible(); + await expect + .poll( + () => menu.evaluate((el) => el.contains(document.activeElement)), + { message: 'focus inside the open menu' } + ) + .toBe(true); + return menu; +} diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts index 37f86d4d5..1770decf5 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts @@ -590,7 +590,7 @@ test('launch options target the workspace source cards and the shared sentinel', assert.equal(options.splashId, 'initial-splash'); assert.equal(options.routeFragment, '/workspace'); assert.match(options.cardSelector, /dashboard-recent-sources-rail-card/); - assert.match(options.cardSelector, /app-playlist-item/); + assert.match(options.cardSelector, /app-source-row/); assert.deepEqual(options.settle, { capMs: JOURNEY_SETTLE_CAP_MS, quietMs: JOURNEY_SETTLE_QUIET_MS, @@ -921,7 +921,7 @@ test('open-source options start at the portal card and end on the source route', options.startClick?.selector ?? '', /dashboard-recent-sources-rail-card/ ); - assert.match(options.startClick?.selector ?? '', /app-playlist-item/); + assert.match(options.startClick?.selector ?? '', /app-source-row/); assert.equal(options.routeFragment, '/workspace/xtreams/'); assert.match(options.cardSelector, /app-grid-list mat-card/); assert.match(options.cardSelector, /channel-item/); diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts index 7ee9bac31..f6d764981 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts @@ -57,7 +57,7 @@ export const JOURNEY_PLAYBACK_START_SELECTOR = export const JOURNEY_PLAYBACK_VIDEO_SELECTOR = 'app-web-player-view video'; /** The Xtream portal card on the dashboard or its row on /workspace/sources. */ export const JOURNEY_OPEN_SOURCE_START_SELECTOR = - '[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item'; + '[data-test-id="dashboard-recent-sources-rail-card"], app-source-row'; /** * Bridge method used for the sentinels. The preload emits the trace event * before it forwards the call, and `SOURCE_HEALTH_CANCEL` only looks the id @@ -862,7 +862,7 @@ export function createLaunchJourneyProbeOptions( ): JourneyRendererProbeOptions { return { cardSelector: - '[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item', + '[data-test-id="dashboard-recent-sources-rail-card"], app-source-row', cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY, ...(idleWindowMs === null ? {} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts index bcb95d21d..ba5f745fc 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -262,7 +262,7 @@ async function runIteration( await renderer.markOperationStart(); const row = sourceRowByTitle(app.mainWindow, SOURCE_TITLE).first(); - await row.locator('.refresh-btn').click(); + await row.locator('.source-row__refresh').click(); await renderer.waitForCancelClick(); await Promise.all([ renderer.waitForTerminal(), diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts index e61c1e6c9..22fab9ebd 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts @@ -301,7 +301,7 @@ async function installRendererProbe( const epoch = (): number => performance.timeOrigin + performance.now(); const sourceRow = (): Element | undefined => - Array.from(document.querySelectorAll('app-playlist-item')).find( + Array.from(document.querySelectorAll('app-source-row')).find( (element) => element.textContent?.includes(title) ); const state = { @@ -330,7 +330,7 @@ async function installRendererProbe( const row = sourceRow(); const message = row - ?.querySelector('.busy-state__message') + ?.querySelector('.source-row__busy-message') ?.textContent?.trim() .toLowerCase() ?? ''; for (const phase of ['fetch', 'pars', 'sav']) { @@ -345,9 +345,10 @@ async function installRendererProbe( state.cancelClickEpochMs !== null && state.uiSettledEpochMs === null && row !== undefined && - row.querySelector('.busy-state__message') === null && - row.querySelector('.action-spinner') === null && - row.querySelector('.cancel-btn') === null + row.querySelector('.source-row__busy-message') === null && + row.querySelector('.source-row__refresh mat-spinner') === + null && + row.querySelector('.source-row__cancel') === null ) { state.uiSettledEpochMs = epoch(); state.uiPaintFrameRequestId = requestAnimationFrame(() => { @@ -421,7 +422,9 @@ async function installRendererProbe( event.phase === 'parsing' && state.cancelClickEpochMs === null ) { - const button = sourceRow()?.querySelector('.cancel-btn'); + const button = sourceRow()?.querySelector( + '.source-row__cancel' + ); state.cancelButtonFound = button instanceof HTMLButtonElement; if (button instanceof HTMLButtonElement) { diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts index e6bfbdabe..567bf70e8 100644 --- a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -10,7 +10,6 @@ import { JOURNEY_CD_TICK_COUNTER_KEY } from './journey-renderer-probe'; interface TargetConfiguration { configurations?: Record>; - defaultConfiguration?: string; dependsOn?: unknown; executor?: unknown; options?: Record; @@ -163,12 +162,7 @@ test('only the web performance build installs the tick counter the journeys read for (const [name, configuration] of Object.entries( webProject.targets['build'].configurations ?? {} )) { - if ( - name === 'electron-performance' || - name === 'electron-performance-zoneless' - ) { - continue; - } + if (name === 'electron-performance') continue; assert.doesNotMatch( JSON.stringify(configuration['fileReplacements'] ?? []), /environment\.performance/, @@ -186,12 +180,7 @@ test('only development web builds provide the NgRx store devtools', () => { replace: 'apps/web/src/environments/store-devtools.providers.ts', with: 'apps/web/src/environments/store-devtools.providers.dev.ts', }; - // electron-e2e-zoneless is electron-e2e plus the zoneless swap. - const developmentConfigurations = new Set([ - 'development', - 'electron-e2e', - 'electron-e2e-zoneless', - ]); + const developmentConfigurations = new Set(['development', 'electron-e2e']); assert.match( readFileSync(join(environments, 'store-devtools.providers.ts'), 'utf8'), @@ -230,42 +219,23 @@ test('only development web builds provide the NgRx store devtools', () => { } }); -// Plan item C6 measures zoneless change detection behind a build-time flag: -// each *-zoneless configuration is its base configuration plus one swap of -// the change-detection providers, and nothing else selects that swap. -test('the zoneless flag is opt-in through the *-zoneless web configurations only', () => { - const configurations = webProject.targets['build'].configurations ?? {}; - const zonelessReplacement = { - replace: 'apps/web/src/environments/change-detection.providers.ts', - with: 'apps/web/src/environments/change-detection.providers.zoneless.ts', - }; +// Plan item C6: the renderer runs zoneless. A zone.js polyfill or a zone +// provider would bring back a tick after every macrotask and make the +// change-detection tick counters racy again. +test('the web app bootstraps zoneless without the zone.js polyfill', () => { + const build = webProject.targets['build']; + const configurations = Object.entries(build.configurations ?? {}); - for (const base of ['electron-performance', 'electron-e2e']) { - const baseConfiguration = configurations[base]; - const zoneless = configurations[`${base}-zoneless`]; - assert.ok(zoneless, `web:build must define ${base}-zoneless`); - assert.ok(baseConfiguration, `web:build must define ${base}`); - const { fileReplacements: baseReplacements, ...baseRest } = - baseConfiguration; - const { fileReplacements, ...rest } = zoneless; - assert.deepEqual(rest, baseRest, base); - assert.deepEqual(fileReplacements, [ - ...((baseReplacements as unknown[] | undefined) ?? []), - zonelessReplacement, - ]); + assert.equal(build.options?.['polyfills'], undefined); + for (const [name, configuration] of configurations) { + assert.equal(configuration['polyfills'], undefined, name); } - for (const [name, configuration] of Object.entries(configurations)) { - if (name.endsWith('-zoneless')) continue; - assert.doesNotMatch( - JSON.stringify(configuration['fileReplacements'] ?? []), - /change-detection\.providers/, - name - ); - } - assert.equal( - webProject.targets['build'].defaultConfiguration, - 'production' + const appConfig = readFileSync( + join(workspaceRoot, 'apps/web/src/app/app.config.ts'), + 'utf8' ); + assert.match(appConfig, /provideZonelessChangeDetection\(\)/); + assert.doesNotMatch(appConfig, /provideZoneChangeDetection/); }); test('the resolved web build cache output is the renderer directory', () => { diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts index ea06941c6..795021039 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts @@ -152,11 +152,11 @@ export async function installXtreamRendererProbe( ); const sourceRowAbsent = (): boolean => !Array.from( - document.querySelectorAll('app-playlist-item') + document.querySelectorAll('app-source-row') ).some( (row) => row - .querySelector('.playlist-title') + .querySelector('.source-row__name') ?.textContent?.trim() === state.sourceTitle ); const terminalDomGate = (): boolean => { diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts index 31782aebc..97e293dce 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts @@ -32,14 +32,14 @@ describe('Xtream scenario driver source wiring', () => { "'#serverUrl'", "'#username'", "'#password'", - "'app-playlist-item'", - "'.refresh-btn'", - "'.delete-btn'", + "'app-workspace-sources app-source-row'", + "'.source-row__refresh'", + "'.source-row__more'", "'Refresh playlist'", "'Remove playlist'", - "'.busy-state__message'", - "'.action-spinner'", - "'.cancel-btn'", + "'.source-row__busy'", + "'mat-spinner'", + "'.source-row__cancel'", "'.workspace-loading-overlay'", ]) { assert.ok(ui.includes(selector), `missing ${selector}`); @@ -68,6 +68,7 @@ describe('Xtream scenario driver source wiring', () => { ); assert.ok(driver.includes('installXtreamCancellationObserver')); assert.ok(driver.includes('installXtreamOperationWindow')); + assert.ok(driver.includes("'delete-playlist'")); assert.ok(driver.includes('runBackgroundProbe')); assert.doesNotMatch(combined, /waitForTimeout\s*\(/); assert.doesNotMatch(combined, /setTimeout\s*\(/); diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts index 17e187503..2f7b68f72 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts @@ -157,15 +157,18 @@ describe('Xtream Playwright scenario driver', () => { }); it('does not require a misleading portal-status badge before refresh or delete', async () => { - for (const [scenarioId, selector, confirmLabel] of [ + for (const [scenarioId, selector, confirmClick] of [ [ XTREAM_SCENARIO_ID.REFRESH_LARGE, - '.refresh-btn', - 'Refresh playlist', + '.source-row__refresh', + 'name=Refresh playlist;exact=true', + ], + [ + XTREAM_SCENARIO_ID.DELETE_LARGE, + '.source-row__more', + '.source-removal-toast__close', ], - [XTREAM_SCENARIO_ID.DELETE_LARGE, '.delete-btn', 'Remove playlist'], ] as const) { - const confirmClick = `name=${confirmLabel};exact=true`; const fake = new FakePage(); const prepared = await prepareXtreamScenario( fake.asPage(), @@ -188,6 +191,54 @@ describe('Xtream Playwright scenario driver', () => { } }); + it('arms the playlist delete before its commit click and ends on its completion', async () => { + const fake = new FakePage(); + const completed = { + active: false, + operation: 'delete-playlist', + operationId: 'delete-playlist-1', + playlistId: 'playlist-1', + startedEpochMs: 100, + terminalEpochMs: 150, + terminalStatus: 'completed', + }; + fake.operationSnapshot = completed; + const prepared = await prepareXtreamScenario( + fake.asPage(), + scenarioOptions(XTREAM_SCENARIO_ID.DELETE_LARGE) + ); + // Preparation stops at the open menu: Remove would start the Undo + // window, which commits on its own before a slow capture setup ends. + assert.equal(fake.clicksMatching('filter(Remove playlist)'), 0); + await prepared.trigger(); + assert.equal(fake.clicksMatching('filter(Remove playlist)'), 1); + const armIndex = fake.log.indexOf('evaluate:install-operation'); + const clickIndex = fake.log.findIndex( + (entry) => + entry.startsWith('click:') && + entry.includes('.source-removal-toast__close') + ); + assert.ok(armIndex >= 0, 'the delete window is armed'); + assert.ok(armIndex < clickIndex, 'before the commit click'); + + await prepared.waitForTerminal(); + assert.ok( + fake.log.indexOf('waitForFunction:operation-terminal') > clickIndex + ); + + const failed = new FakePage(); + failed.operationSnapshot = { ...completed, terminalStatus: 'error' }; + const failing = await prepareXtreamScenario( + failed.asPage(), + scenarioOptions(XTREAM_SCENARIO_ID.DELETE_LARGE) + ); + await failing.trigger(); + await assert.rejects( + () => failing.waitForTerminal(), + /xtream-delete-did-not-complete/ + ); + }); + it('arms cancellation after preparation and immediately before the final Add trigger', async () => { const fake = new FakePage(); const prepared = await prepareXtreamScenario( diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts index eb1d8f2ac..9acba2d41 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts @@ -19,7 +19,8 @@ import { prepareXtreamSourceActionTrigger, waitForXtreamCancellationTerminal, waitForXtreamCatalogTerminal, - waitForXtreamDeleteTerminal, + removeXtreamSource, + waitForXtreamSourceRowGone, } from './xtream-scenario-ui'; import { assertXtreamMockOrigin } from './xtream-control-client'; import { assertXtreamBackgroundRefreshActive } from './xtream-renderer-probe'; @@ -106,6 +107,7 @@ export async function prepareXtreamScenario( return createPreparedScenario({ backgroundOperation, cancellationObserver: null, + deleteOperation: null, options, page, trigger, @@ -119,6 +121,8 @@ export async function prepareXtreamScenario( interface PreparedState { readonly backgroundOperation: XtreamOperationWindow | null; cancellationObserver: XtreamCancellationObserver | null; + /** The playlist delete, armed before its commit click. */ + deleteOperation: XtreamOperationWindow<'delete-playlist'> | null; readonly options: PrepareXtreamScenarioOptions; readonly page: Page; readonly trigger: Locator; @@ -136,6 +140,7 @@ function createPreparedScenario(state: PreparedState): PreparedXtreamScenario { await Promise.allSettled([ state.cancellationObserver?.dispose(), state.backgroundOperation?.dispose(), + state.deleteOperation?.dispose(), ]); }; const prepared: PreparedXtreamScenario = { @@ -189,8 +194,21 @@ function createPreparedScenario(state: PreparedState): PreparedXtreamScenario { state.cancellationObserver = await installXtreamCancellationObserver(state.page); } + if (state.options.scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE) { + // A fast delete can bring its row back and drop it again + // before a UI wait starts; the operation's own events cannot + // be missed once armed. + state.deleteOperation = await installXtreamOperationWindow( + state.page, + 'delete-playlist' + ); + } + let commit = state.trigger; + if (state.options.scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE) { + commit = await removeXtreamSource(state.page, state.trigger); + } const triggerEpochMs = Date.now(); - await state.trigger.click(); + await commit.click(); return { scenarioId: state.options.scenarioId, triggerEpochMs, @@ -224,12 +242,20 @@ async function waitForScenarioTerminal( cancellation = await state.cancellationObserver.waitForClick(); await waitForXtreamCancellationTerminal(state.page); break; - case XTREAM_SCENARIO_ID.DELETE_LARGE: - await waitForXtreamDeleteTerminal( + case XTREAM_SCENARIO_ID.DELETE_LARGE: { + if (!state.deleteOperation) { + throw new Error('xtream-delete-operation-unavailable'); + } + const deleted = await state.deleteOperation.waitForTerminal(); + if (deleted.terminalStatus !== 'completed') { + throw new Error('xtream-delete-did-not-complete'); + } + await waitForXtreamSourceRowGone( state.page, state.options.sourceTitle ); break; + } case XTREAM_SCENARIO_ID.BACKGROUND_UI: if (readBackgroundProbe() === null) { throw new Error('xtream-background-probe-incomplete'); diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-operation-observer.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-operation-observer.ts index e582ab632..4ddc900e4 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-operation-observer.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-operation-observer.ts @@ -2,11 +2,16 @@ import type { Page } from '@playwright/test'; const OPERATION_STATE_KEY = '__iptvnatorXtreamScenarioOperationWindow'; const DELETE_OPERATION = 'delete-xtream-content'; +/** A content delete before a refresh, or a whole playlist's delete. */ +export type XtreamObservedOperation = + typeof DELETE_OPERATION | 'delete-playlist'; const OPERATION_TIMEOUT_MS = 120_000; -export interface XtreamOperationWindowSnapshot { +export interface XtreamOperationWindowSnapshot< + Operation extends XtreamObservedOperation = typeof DELETE_OPERATION, +> { readonly active: boolean; - readonly operation: typeof DELETE_OPERATION; + readonly operation: Operation; readonly operationId: string; readonly playlistId: string; readonly startedEpochMs: number; @@ -14,16 +19,21 @@ export interface XtreamOperationWindowSnapshot { readonly terminalStatus: 'cancelled' | 'completed' | 'error' | null; } -export interface XtreamOperationWindow { - assertActive(): Promise; +export interface XtreamOperationWindow< + Operation extends XtreamObservedOperation = typeof DELETE_OPERATION, +> { + assertActive(): Promise>; dispose(): Promise; - waitForActive(): Promise; - waitForTerminal(): Promise; + waitForActive(): Promise>; + waitForTerminal(): Promise>; } -export async function installXtreamOperationWindow( - page: Page -): Promise { +export async function installXtreamOperationWindow< + Operation extends XtreamObservedOperation = typeof DELETE_OPERATION, +>( + page: Page, + operation: Operation = DELETE_OPERATION as Operation +): Promise> { await page.evaluate( ({ command, operation, stateKey }) => { interface State { @@ -104,20 +114,20 @@ export async function installXtreamOperationWindow( }, { command: 'install-operation', - operation: DELETE_OPERATION, + operation, stateKey: OPERATION_STATE_KEY, } ); return { - assertActive: () => assertOperationActive(page), + assertActive: () => assertOperationActive(page, operation), dispose: () => disposeOperationState(page), async waitForActive() { await waitForOperationState(page, 'operation-active'); - return assertOperationActive(page); + return assertOperationActive(page, operation); }, async waitForTerminal() { await waitForOperationState(page, 'operation-terminal'); - return readOperationSnapshot(page); + return readOperationSnapshot(page, operation); }, }; } @@ -144,19 +154,21 @@ async function waitForOperationState( ); } -async function assertOperationActive( - page: Page -): Promise { - const snapshot = await readOperationSnapshot(page); +async function assertOperationActive( + page: Page, + operation: Operation +): Promise> { + const snapshot = await readOperationSnapshot(page, operation); if (!snapshot.active || snapshot.terminalEpochMs !== null) { throw new Error('xtream-background-action-outside-delete-window'); } return snapshot; } -async function readOperationSnapshot( - page: Page -): Promise { +async function readOperationSnapshot( + page: Page, + operation: Operation +): Promise> { const value = await page.evaluate( ({ stateKey }) => { const target = globalThis as unknown as Record; @@ -183,7 +195,7 @@ async function readOperationSnapshot( }, { command: 'snapshot-operation', stateKey: OPERATION_STATE_KEY } ); - if (!isOperationSnapshot(value)) { + if (!isOperationSnapshot(value, operation)) { throw new Error('xtream-operation-window-invalid'); } return value; @@ -202,16 +214,17 @@ async function disposeOperationState(page: Page): Promise { ); } -function isOperationSnapshot( - value: unknown -): value is XtreamOperationWindowSnapshot { +function isOperationSnapshot( + value: unknown, + operation: Operation +): value is XtreamOperationWindowSnapshot { if (!isRecord(value)) return false; const active = value['active']; const startedEpochMs = value['startedEpochMs']; const terminalEpochMs = value['terminalEpochMs']; const terminalStatus = value['terminalStatus']; return ( - value['operation'] === DELETE_OPERATION && + value['operation'] === operation && typeof active === 'boolean' && finitePositive(startedEpochMs) && nonEmptyString(value['operationId']) && diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts index e2c7b83e0..460d8bbcf 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts @@ -68,28 +68,54 @@ export async function prepareXtreamSourceActionTrigger( const row = xtreamSourceRow(page, sourceTitle); await row.waitFor({ state: 'visible' }); for (const selector of [ - '.busy-state__message', - '.action-spinner', - '.cancel-btn', + '.source-row__busy', + 'mat-spinner', + '.source-row__cancel', ]) { await row.locator(selector).waitFor({ state: 'detached' }); } - const actionSelector = - action === 'refresh' ? '.refresh-btn' : '.delete-btn'; - const actionButton = row.locator(actionSelector); - await assertTriggerReady(actionButton, `xtream-source-${action}`); - await actionButton.click(); + if (action === 'refresh') { + const refresh = row.locator('.source-row__refresh'); + await assertTriggerReady(refresh, 'xtream-source-refresh'); + await refresh.click(); + const dialog = page.locator('mat-dialog-container').last(); + await dialog.waitFor({ state: 'visible' }); + const confirmation = dialog + .getByRole('button', { name: 'Refresh playlist', exact: true }) + .last(); + await assertTriggerReady(confirmation, 'xtream-source-refresh-confirm'); + return confirmation; + } - const dialog = page.locator('mat-dialog-container').last(); - await dialog.waitFor({ state: 'visible' }); - const confirmation = dialog - .getByRole('button', { - name: action === 'refresh' ? 'Refresh playlist' : 'Remove playlist', - exact: true, - }) - .last(); - await assertTriggerReady(confirmation, `xtream-source-${action}-confirm`); - return confirmation; + // Remove starts the Undo window, which commits on its own after four + // seconds: preparation stops at the open menu, and the trigger removes + // and commits at once (removeXtreamSource). + const more = row.locator('.source-row__more'); + await assertTriggerReady(more, 'xtream-source-delete-menu'); + await more.click(); + const remove = page + .locator('.cdk-overlay-pane [mat-menu-item]') + .filter({ hasText: 'Remove playlist' }) + .first(); + await assertTriggerReady(remove, 'xtream-source-delete'); + return remove; +} + +/** + * Clicks Remove and returns the Undo toast's close button, which commits the + * delete without waiting out the Undo window: the measured delete starts + * with that click. + */ +export async function removeXtreamSource( + page: Page, + remove: Locator +): Promise { + await remove.click(); + const commit = page.locator( + 'mat-snack-bar-container.app-toast .source-removal-toast__close' + ); + await assertTriggerReady(commit, 'xtream-source-delete-confirm'); + return commit; } export async function waitForXtreamCatalogTerminal(page: Page): Promise { @@ -125,7 +151,11 @@ export async function waitForXtreamCancellationTerminal( ); } -export async function waitForXtreamDeleteTerminal( +/** + * After the database reports the delete complete: the row, back as + * "Removing…" while it ran or never shown again for a fast delete, leaves. + */ +export async function waitForXtreamSourceRowGone( page: Page, sourceTitle: string ): Promise { @@ -157,9 +187,9 @@ async function selectXtreamMethod(dialog: Locator): Promise { function xtreamSourceRow(page: Page, sourceTitle: string): Locator { const exactTitle = new RegExp(`^\\s*${escapeRegex(sourceTitle)}\\s*$`, 'u'); return page - .locator('app-playlist-item') + .locator('app-workspace-sources app-source-row') .filter({ - has: page.locator('.playlist-title', { + has: page.locator('.source-row__name', { hasText: exactTitle, }), }) diff --git a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts index aefb1725c..f152c54bb 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts @@ -178,7 +178,7 @@ class SearchCompletionFakePage { } this.searchGateObserved = /searchParams\.get\((['"])q\1\)/u.test(source) && - source.includes('app-playlist-item') && + source.includes('app-source-row') && source.includes('no-results-state'); if (this.searchGateObserved) { this.searchResultsApplied = true; diff --git a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts index 579ece981..f7198880e 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts @@ -154,14 +154,14 @@ export async function runXtreamBackgroundUiActions( ); const resultRows = Array.from( document.querySelectorAll( - 'app-workspace-sources app-playlist-item' + 'app-workspace-sources app-source-row' ) ); const resultPainted = resultRows.length > 0 ? resultRows.every((row) => ( - row.querySelector('.playlist-title') + row.querySelector('.source-row__name') ?.textContent ?? '' ) .trim() diff --git a/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts b/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts index 024972a30..43a799b6d 100644 --- a/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts +++ b/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts @@ -30,6 +30,7 @@ import { writeTemporaryM3uFile, xtreamMockServer, } from './electron-test-fixtures'; +import { waitForMenuFocus } from './menu-keyboard'; import { routePlayableStreams, startAndConfirmPlayback, @@ -245,44 +246,41 @@ test.describe('Electron Playlist Switcher', () => { await waitForM3uCatalog(app.mainWindow); await openPlaylistFavorites(app.mainWindow); - await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( - 1 - ); - await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( - 0 - ); + await expect( + channelItemByTitle(app.mainWindow, localAChannelName) + ).toHaveCount(1); + await expect( + channelItemByTitle(app.mainWindow, localBChannelName) + ).toHaveCount(0); await switchPlaylistFromHeader(app.mainWindow, localBDisplayName); await expectPathname( app.mainWindow, /\/workspace\/global-favorites$/ ); - await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( - 1 - ); - await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( - 0 - ); + await expect( + channelItemByTitle(app.mainWindow, localBChannelName) + ).toHaveCount(1); + await expect( + channelItemByTitle(app.mainWindow, localAChannelName) + ).toHaveCount(0); await openPlaylistRecent(app.mainWindow); - await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( - 1 - ); - await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( - 0 - ); + await expect( + channelItemByTitle(app.mainWindow, localBChannelName) + ).toHaveCount(1); + await expect( + channelItemByTitle(app.mainWindow, localAChannelName) + ).toHaveCount(0); await switchPlaylistFromHeader(app.mainWindow, localADisplayName); - await expectPathname( - app.mainWindow, - /\/workspace\/global-recent$/ - ); - await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( - 1 - ); - await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( - 0 - ); + await expectPathname(app.mainWindow, /\/workspace\/global-recent$/); + await expect( + channelItemByTitle(app.mainWindow, localAChannelName) + ).toHaveCount(1); + await expect( + channelItemByTitle(app.mainWindow, localBChannelName) + ).toHaveCount(0); } finally { await closeElectronApp(app); } @@ -319,7 +317,9 @@ test.describe('Electron Playlist Switcher', () => { username: xtreamStressUsername, }); - const overlay = app.mainWindow.locator('.workspace-loading-overlay'); + const overlay = app.mainWindow.locator( + '.workspace-loading-overlay' + ); await expect(overlay).toBeVisible({ timeout: 15000 }); await waitForDbOperationEvent(app.mainWindow, { operation: 'save-content', @@ -428,17 +428,171 @@ test.describe('Electron Playlist Switcher', () => { await switchPlaylistFromHeader(app.mainWindow, offlineXtreamName); await waitForXtreamCatalog(app.mainWindow); await expectXtreamUnavailableWarning(app.mainWindow); - await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount( - 0 - ); + await expect( + app.mainWindow.locator('.xtream-content-gate') + ).toHaveCount(0); app = await restartElectronApp(app, dataDir); await waitForXtreamCatalog(app.mainWindow); await expectXtreamUnavailableWarning(app.mainWindow); - await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount( - 0 + await expect( + app.mainWindow.locator('.xtream-content-gate') + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + + test('runs from the keyboard: Cmd/Ctrl+P, filter, arrows, Enter and Escape', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [ + { + groupTitle: 'News', + name: 'Switcher Local A', + url: 'https://streams.example.test/switcher-local-a.m3u8', + }, + ]); + const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [ + { + groupTitle: 'Sports', + name: 'Switcher Local B', + url: 'https://streams.example.test/switcher-local-b.m3u8', + }, + ]); + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + + try { + await importM3uPlaylistFromNativeDialog(app, localAPath); + await importM3uPlaylistFromNativeDialog(app, localBPath); + await addXtreamPortal(page, { name: xtreamAName }); + await waitForXtreamCatalog(page); + + const trigger = page.getByTestId('playlist-switcher-trigger'); + const panel = page.locator('app-playlist-switcher-panel'); + const search = panel.locator('input[role="combobox"]'); + await expect(playlistSwitcherTitle(page)).toContainText( + xtreamAName ); + + await page.keyboard.press('ControlOrMeta+p'); + await expect(search).toBeFocused(); + // No list shows a credential: the Xtream row names the account + // on purpose, never its password. + await expect(panel).not.toContainText(defaultXtreamPassword); + await expect(panel).not.toContainText('password='); + + await page.keyboard.type('switcher-local'); + await expect(switcherOptions(page)).toHaveCount(2); + // A search highlights its first match; ↓ moves on, ↑ wraps. + const options = switcherOptions(page); + const firstId = (await options.nth(0).getAttribute('id')) ?? ''; + const secondId = (await options.nth(1).getAttribute('id')) ?? ''; + await expect(search).toHaveAttribute( + 'aria-activedescendant', + firstId + ); + await page.keyboard.press('ArrowDown'); + await expect(search).toHaveAttribute( + 'aria-activedescendant', + secondId + ); + await page.keyboard.press('ArrowDown'); + await expect(search).toHaveAttribute( + 'aria-activedescendant', + firstId + ); + await page.keyboard.press('ArrowUp'); + await expect(search).toHaveAttribute( + 'aria-activedescendant', + secondId + ); + const targetName = + ( + await options + .nth(1) + .locator('.source-row__name') + .textContent() + )?.trim() ?? ''; + await page.keyboard.press('Enter'); + + await expect(panel).toHaveCount(0); + await waitForM3uCatalog(page); + await expect(playlistSwitcherTitle(page)).toContainText(targetName); + + // Escape closes and puts focus back on the trigger. + await page.keyboard.press('ControlOrMeta+p'); + await expect(search).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(panel).toHaveCount(0); + await expect(trigger).toBeFocused(); + + // In a short window only the list shrinks and scrolls; the + // search field and the type filter keep their height. + const viewport = page.viewportSize(); + await page.setViewportSize({ + width: viewport?.width ?? 1280, + height: 320, + }); + await page.keyboard.press('ControlOrMeta+p'); + await expect(search).toBeFocused(); + const chrome = await panel.evaluate((host) => { + const height = (element: Element | null) => + element?.getBoundingClientRect().height ?? 0; + const list = host.querySelector('.switcher__list'); + return { + search: height(host.querySelector('.switcher__search')), + toggles: Array.from( + host.querySelectorAll('.mat-button-toggle'), + height + ), + listScrolls: + !!list && list.scrollHeight > list.clientHeight, + }; + }); + expect(chrome.search, JSON.stringify(chrome)).toBeCloseTo(36, 0); + expect(chrome.toggles.length, JSON.stringify(chrome)).toBe(3); + for (const toggle of chrome.toggles) { + expect(toggle, JSON.stringify(chrome)).toBeGreaterThanOrEqual( + 30 + ); + } + expect(chrome.listScrolls, JSON.stringify(chrome)).toBe(true); + await page.keyboard.press('Escape'); + await expect(panel).toHaveCount(0); + if (viewport) await page.setViewportSize(viewport); + + // A delete from the keyboard: the popover closes, since its + // focus trap would keep Tab from Undo; Undo takes the focus and + // gives it back to the trigger. + await page.keyboard.press('ControlOrMeta+p'); + await expect(search).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Shift+F10'); + const rowMenu = await waitForMenuFocus(page); + await page.keyboard.press('End'); + await expect( + rowMenu.getByRole('menuitem', { name: /Remove/ }) + ).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(panel).toHaveCount(0); + const undo = page.locator('.source-removal-toast__undo'); + await expect(undo).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(undo).toHaveCount(0); + await expect(trigger).toBeFocused(); + + await openSources(page); + await expect( + page.locator('app-workspace-sources app-source-row') + ).toHaveCount(3); + await expect( + page.locator('app-workspace-sources') + ).not.toContainText(defaultXtreamPassword); } finally { await closeElectronApp(app); } @@ -446,12 +600,12 @@ test.describe('Electron Playlist Switcher', () => { }); async function openPlaylistSwitcher(page: Page): Promise { - await page - .locator('app-playlist-switcher .playlist-switcher-trigger') - .click(); - await expect( - page.locator('.cdk-overlay-pane .playlist-item').first() - ).toBeVisible(); + await page.getByTestId('playlist-switcher-trigger').click(); + await expect(switcherOptions(page).first()).toBeVisible(); +} + +function switcherOptions(page: Page) { + return page.locator('app-playlist-switcher-panel [role="option"]'); } async function switchPlaylistFromHeader( @@ -459,16 +613,11 @@ async function switchPlaylistFromHeader( title: string ): Promise { await openPlaylistSwitcher(page); - const item = page - .locator('.cdk-overlay-pane .playlist-item') - .filter({ hasText: title }) - .first(); + const item = switcherOptions(page).filter({ hasText: title }).first(); await expect(item).toBeVisible(); await item.click(); - await expect(page.locator('.cdk-overlay-pane .playlist-item')).toHaveCount( - 0 - ); + await expect(page.locator('app-playlist-switcher-panel')).toHaveCount(0); } async function expectPathname(page: Page, pattern: RegExp): Promise { @@ -503,16 +652,21 @@ async function selectSettingsOption( } async function expectXtreamUnavailableWarning(page: Page): Promise { - await expect( - page.getByTestId('xtream-offline-warning') - ).toBeVisible({ timeout: 20000 }); + await expect(page.getByTestId('xtream-offline-warning')).toBeVisible({ + timeout: 20000, + }); } -async function toggleFavoriteForChannel(page: Page, title: string): Promise { +async function toggleFavoriteForChannel( + page: Page, + title: string +): Promise { const item = channelItemByTitle(page, title).first(); await expect(item).toBeVisible({ timeout: 20000 }); await item.hover(); await item.locator('.favorite-button').first().click(); - await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(/star/); + await expect(item.locator('.favorite-button mat-icon').first()).toHaveText( + /star/ + ); } diff --git a/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts b/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts index 83dc3802d..1a1a8e45b 100644 --- a/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts +++ b/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts @@ -9,6 +9,7 @@ import { waitForM3uCatalog, writeTemporaryM3uFile, } from './electron-test-fixtures'; +import { waitForMenuFocus } from './menu-keyboard'; // --------------------------------------------------------------------------- // A source row opens from the keyboard. Each row has its own Tab stop: the @@ -31,7 +32,7 @@ type RowRing = { function readRowRing(page: Page): Promise { return page.evaluate(() => { const focused = document.activeElement as HTMLElement; - const row = focused.closest('.playlist-item') as HTMLElement; + const row = focused.closest('.source-row') as HTMLElement; const probe = document.createElement('span'); probe.style.color = 'var(--app-focus-ring)'; row.append(probe); @@ -93,16 +94,19 @@ test.describe('Electron Sources keyboard', () => { timeout: 30_000, }); await openSources(page); - await expect(page.locator('app-playlist-item')).toHaveCount(2, { + await expect( + page.locator('app-workspace-sources app-source-row') + ).toHaveCount(2, { timeout: 30_000, }); // Bravo opened last, so it is the active source; open the other. const row = sourceRowByTitle(page, 'keyboard-alpha').first(); - await expect(row.locator('.playlist-item')).not.toHaveClass( - /\bselected\b/ + await expect(row.locator('.source-row__body')).not.toHaveAttribute( + 'aria-current', + 'true' ); const title = ( - await row.locator('.playlist-title').textContent() + await row.locator('.source-row__name').textContent() )?.trim(); expect(title).toBeTruthy(); @@ -153,7 +157,7 @@ test.describe('Electron Sources keyboard', () => { await expect(open).not.toBeFocused(); expect( await row - .locator('.playlist-item') + .locator('.source-row') .evaluate((el) => getComputedStyle(el).outlineStyle) ).toBe('none'); await page.keyboard.press('Shift+Tab'); @@ -172,4 +176,70 @@ test.describe('Electron Sources keyboard', () => { await closeElectronApp(app); } }); + + test('@m3u @electron deletes from the row menu and undoes from the toast, keyboard only', async ({ + dataDir, + }) => { + const alpha = writeTemporaryM3uFile(dataDir, 'undo-alpha.m3u', [ + { + groupTitle: 'News', + name: 'Undo Alpha News', + url: 'https://streams.example.test/undo-alpha.m3u8', + }, + ]); + const bravo = writeTemporaryM3uFile(dataDir, 'undo-bravo.m3u', [ + { + groupTitle: 'Sports', + name: 'Undo Bravo Sports', + url: 'https://streams.example.test/undo-bravo.m3u8', + }, + ]); + const app = await launchElectronApp(dataDir, { + appArgs: [alpha, bravo], + }); + const page = app.mainWindow; + const rows = page.locator('app-workspace-sources app-source-row'); + + try { + await page.waitForURL(/\/workspace\/playlists\/.+/, { + timeout: 30_000, + }); + await openSources(page); + await expect(rows).toHaveCount(2, { timeout: 30_000 }); + const before = await rows + .locator('.source-row__name') + .allTextContents(); + + // Focus the first row's ⋯ and open its menu from the keyboard. + const row = rows.first(); + await row.locator('.source-row__more').focus(); + await page.keyboard.press('Enter'); + const remove = page.getByRole('menuitem', { + name: 'Remove playlist', + }); + await expect(remove).toBeVisible(); + await waitForMenuFocus(page); + await page.keyboard.press('End'); + await expect(remove).toBeFocused(); + await page.keyboard.press('Enter'); + + // The row leaves at once; Undo takes the focus the row had. + await expect(rows).toHaveCount(1); + const undo = page.locator( + 'mat-snack-bar-container.app-toast .source-removal-toast__undo' + ); + await expect(undo).toBeFocused(); + await page.keyboard.press('Enter'); + + await expect(rows).toHaveCount(2); + expect( + await rows.locator('.source-row__name').allTextContents() + ).toEqual(before); + await expect( + page.locator('mat-snack-bar-container.app-toast') + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); }); diff --git a/apps/electron-backend-e2e/src/sources.e2e.ts b/apps/electron-backend-e2e/src/sources.e2e.ts index 7c21e103d..072585c71 100644 --- a/apps/electron-backend-e2e/src/sources.e2e.ts +++ b/apps/electron-backend-e2e/src/sources.e2e.ts @@ -717,14 +717,14 @@ https://streams.example.test/url-omega.m3u8 await expect( sourceRowByTitle(app.mainWindow, 'Delete Me Stalker').locator( - 'app-source-health-indicator [data-state]' + '.source-tile__dot[data-state]' ) - ).toHaveAttribute('data-state', 'active', { timeout: 20000 }); + ).toHaveAttribute('data-state', 'ok', { timeout: 20000 }); await expect( sourceRowByTitle(app.mainWindow, 'Delete Me Xtream').locator( - 'app-source-health-indicator [data-state]' + '.source-tile__dot[data-state]' ) - ).toHaveAttribute('data-state', 'active', { timeout: 20000 }); + ).toHaveAttribute('data-state', 'ok', { timeout: 20000 }); await deleteSource(app.mainWindow, deletableLocalSourceDisplayName); await expect( @@ -785,11 +785,20 @@ https://streams.example.test/original-url.m3u8 try { await dropM3uPlaylistOntoWorkspace(app.mainWindow, localFilePath); await waitForM3uCatalog(app.mainWindow); + // Refresh for the active source lives in its switcher row menu. + await app.mainWindow.getByTestId('playlist-switcher-trigger').click(); + const currentRow = app.mainWindow.locator( + 'app-playlist-switcher-panel [role="option"][aria-current="true"]' + ); + await currentRow.locator('.source-row__more').click(); await expect( - app.mainWindow.locator( - 'app-playlist-switcher .trigger-refresh-button' - ) + app.mainWindow.getByRole('menuitem', { name: 'Refresh playlist' }) ).toBeVisible(); + await app.mainWindow.keyboard.press('Escape'); + await app.mainWindow.keyboard.press('Escape'); + await expect( + app.mainWindow.locator('app-playlist-switcher-panel') + ).toHaveCount(0); await goToDashboard(app.mainWindow); const sourceCard = app.mainWindow @@ -822,8 +831,8 @@ https://streams.example.test/original-url.m3u8 await expect( sourceRowByTitle(app.mainWindow, refreshLocalSourceDisplayName) .first() - .locator('.refresh-btn') - ).toBeVisible(); + .locator('.source-row__refresh') + ).toBeAttached(); writeTemporaryM3uFile(dataDir, 'refresh-local-source.m3u', [ { @@ -865,8 +874,8 @@ https://streams.example.test/refreshed-url.m3u8 await expect( sourceRowByTitle(app.mainWindow, 'refresh-url-source.m3u') .first() - .locator('.meta') - ).toContainText('Updated:'); + .locator('.source-row__meta') + ).toContainText('updated'); await waitForSourceRowIdle( app.mainWindow, 'refresh-url-source.m3u' @@ -876,9 +885,11 @@ https://streams.example.test/refreshed-url.m3u8 'refresh-url-source.m3u' ).first(); await expect( - refreshedUrlRow.locator('.playlist-item') - ).not.toHaveClass(/is-busy/); - await expect(refreshedUrlRow.locator('.refresh-btn')).toBeEnabled(); + refreshedUrlRow.locator('.source-row') + ).not.toHaveClass(/source-row--busy/); + await expect( + refreshedUrlRow.locator('.source-row__refresh') + ).toBeEnabled(); await refreshedUrlRow .getByText('refresh-url-source.m3u', { exact: true }) .click(); @@ -904,8 +915,8 @@ https://streams.example.test/refreshed-url.m3u8 await expect( sourceRowByTitle(app.mainWindow, 'Refresh Xtream Source') .first() - .locator('.meta') - ).toContainText('Updated:'); + .locator('.source-row__meta') + ).toContainText('updated'); } finally { await closeElectronApp(app); await urlServer.close(); diff --git a/apps/electron-backend-e2e/src/theme-tokens.e2e.ts b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts index 4db1cecd8..fb368c4fc 100644 --- a/apps/electron-backend-e2e/src/theme-tokens.e2e.ts +++ b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts @@ -47,16 +47,27 @@ async function outlineContrast( }, fillSelector); } -/** Resolves a CSS color (including var() and color-mix()) to rgb()/rgba(). */ -async function resolveColor(page: Page, value: string): Promise { - return page.evaluate((css) => { - const probe = document.createElement('div'); - probe.style.color = css; - document.body.appendChild(probe); - const color = getComputedStyle(probe).color; - probe.remove(); - return color; - }, value); +/** + * Resolves a CSS color (including var() and color-mix()) the way the given + * property serializes it, so it compares with a computed `color` or + * `backgroundColor`. + */ +async function resolveColor( + page: Page, + value: string, + property: 'color' | 'backgroundColor' = 'color' +): Promise { + return page.evaluate( + ({ css, prop }) => { + const probe = document.createElement('div'); + probe.style[prop] = css; + document.body.appendChild(probe); + const color = getComputedStyle(probe)[prop]; + probe.remove(); + return color; + }, + { css: value, prop: property } + ); } async function systemVariable(page: Page, name: string): Promise { @@ -200,7 +211,7 @@ test.describe('Theme tokens', () => { } }); - test('@theme @electron destructive actions name the action and use the error color', async ({ + test('@theme @electron destructive actions name the action and use the danger tint', async ({ dataDir, }) => { const app = await launchElectronApp(dataDir); @@ -211,18 +222,35 @@ test.describe('Theme tokens', () => { for (const theme of ['light', 'dark'] as const) { await applyTheme(page, theme); - const error = await resolveColor(page, 'var(--mat-sys-error)'); + const dangerText = await resolveColor( + page, + 'var(--app-danger-text)' + ); + const dangerSurface = await resolveColor( + page, + 'var(--app-danger-surface)', + 'backgroundColor' + ); const trigger = page.getByRole('button', { name: 'Clear EPG data', exact: true, }); // The `warn` color input was a no-op with M3: the trigger - // rendered in the primary color. + // rendered in the primary color. The kit draws a destructive + // button as a red tint, never a solid error fill, and the + // outlined trigger carries the same tint as a filled one. await expect .poll(() => trigger.evaluate((el) => getComputedStyle(el).color) ) - .toBe(error); + .toBe(dangerText); + await expect + .poll(() => + trigger.evaluate( + (el) => getComputedStyle(el).backgroundColor + ) + ) + .toBe(dangerSurface); await trigger.click(); const dialog = page.locator('mat-dialog-container'); @@ -239,7 +267,12 @@ test.describe('Theme tokens', () => { (el) => getComputedStyle(el).backgroundColor ) ) - .toBe(error); + .toBe(dangerSurface); + await expect + .poll(() => + confirm.evaluate((el) => getComputedStyle(el).color) + ) + .toBe(dangerText); await dialog .getByRole('button', { name: 'Cancel', exact: true }) diff --git a/apps/electron-backend-e2e/src/workspace-route-swap.e2e.ts b/apps/electron-backend-e2e/src/workspace-route-swap.e2e.ts new file mode 100644 index 000000000..cc9ea6378 --- /dev/null +++ b/apps/electron-backend-e2e/src/workspace-route-swap.e2e.ts @@ -0,0 +1,132 @@ +import { Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + expect, + goToDashboard, + launchElectronApp, + resetMockServers, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; + +/** + * Leaving a portal section for the dashboard must replace the routed page + * in one task. With the @angular/animations renderer the router inserted the + * dashboard at once but the old page (and the context panel) left only at + * the end of the next, rAF-coalesced change detection: in between Chromium + * laid out the dashboard below the portal page, at y=720 in a 720 px window, + * and scored its jump into view as a layout shift of 0.337 at 1280 px. + */ + +type RouteSwapProbe = { + overlaps: string[][]; + shiftSources: string[]; +}; + +declare global { + interface Window { + __routeSwapProbe?: RouteSwapProbe; + } +} + +/** Sources whose shift the old page's late removal caused. */ +const SWAP_SHIFT_SOURCES = [ + 'lib-workspace-dashboard-rails', + 'main.workspace-content', +]; + +async function armRouteSwapProbe(page: Page): Promise { + await page.evaluate(() => { + const content = document.querySelector('main.workspace-content'); + if (!content) { + throw new Error('workspace content pane is missing'); + } + const probe: RouteSwapProbe = { overlaps: [], shiftSources: [] }; + window.__routeSwapProbe = probe; + // Mutation callbacks run at the microtask checkpoint after the + // router's activation, before any frame or deferred change detection. + new MutationObserver(() => { + const pages = [...content.children] + .filter((child) => child.tagName !== 'ROUTER-OUTLET') + .map((child) => child.tagName.toLowerCase()); + if (pages.length > 1) { + probe.overlaps.push(pages); + } + }).observe(content, { childList: true }); + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + const sources = + (entry as PerformanceEntry & { + sources?: { node?: Node | null }[]; + }).sources ?? []; + for (const { node } of sources) { + if (node instanceof Element) { + const className = node.classList.contains( + 'workspace-content' + ) + ? '.workspace-content' + : ''; + probe.shiftSources.push( + node.tagName.toLowerCase() + className + ); + } + } + } + }).observe({ type: 'layout-shift' }); + }); +} + +async function waitForTwoFrames(page: Page): Promise { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => + requestAnimationFrame(() => setTimeout(resolve, 0)) + ) + ) + ); +} + +test.describe('Workspace route swap', () => { + test('replaces a portal page with the dashboard without a frame showing both', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + + try { + await app.electronApp.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0].setContentSize(1280, 720); + }); + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + await expect(page).toHaveURL(/\/workspace\/xtreams\/[^/]+\/vod/); + await expect( + page.locator('app-workspace-shell-context-sidebar') + ).toBeVisible(); + + await armRouteSwapProbe(page); + await goToDashboard(page); + await expect( + page.locator('lib-workspace-dashboard-rails') + ).toBeVisible(); + await expect( + page.locator('app-workspace-shell-context-sidebar') + ).toHaveCount(0); + await waitForTwoFrames(page); + + const probe = await page.evaluate(() => window.__routeSwapProbe); + expect(probe?.overlaps).toEqual([]); + expect( + probe?.shiftSources.filter((source) => + SWAP_SHIFT_SOURCES.includes(source) + ) + ).toEqual([]); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts index 0b97a93f3..b13738ea2 100644 --- a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts @@ -25,7 +25,7 @@ const dbWorkerBatchDelayMs = '20'; const dbWorkerRowsPerTransaction = '100'; function playlistRowByName(name: string) { - return `app-playlist-item:has-text("${name}")`; + return `app-workspace-sources app-source-row:has-text("${name}")`; } async function openSources(page: Parameters[0]) { @@ -287,14 +287,13 @@ test.describe('Electron Xtream Responsiveness', () => { ); await expect(playlistRow).toBeVisible({ timeout: 20000 }); - await playlistRow.locator('.delete-btn').click(); - - const confirmDialog = app.mainWindow.locator( - 'mat-dialog-container' - ); - await expect(confirmDialog).toBeVisible(); - await confirmDialog - .getByRole('button', { name: 'Remove playlist', exact: true }) + // Delete from the row menu; the delete starts when the Undo + // toast closes, and the row comes back to show its progress. + await playlistRow.hover(); + await playlistRow.locator('.source-row__more').click(); + await app.mainWindow + .locator('.cdk-overlay-pane [mat-menu-item]') + .filter({ hasText: 'Remove playlist' }) .click(); await waitForDbOperationEvent(app.mainWindow, { @@ -303,10 +302,10 @@ test.describe('Electron Xtream Responsiveness', () => { timeoutMs: 20000, }); - await expect( - playlistRow.locator('.busy-state__message') - ).toBeVisible({ timeout: 20000 }); - const cancel = playlistRow.locator('.cancel-btn'); + await expect(playlistRow.locator('.source-row__busy')).toBeVisible({ + timeout: 20000, + }); + const cancel = playlistRow.locator('.source-row__cancel'); await expect(cancel).toBeVisible(); // The row's action buttons inherit its color; the cancel action // must still read as destructive on the selected row. diff --git a/apps/web-e2e/src/icon-button-names.e2e.ts b/apps/web-e2e/src/icon-button-names.e2e.ts index 18e7b997d..13ad647b7 100644 --- a/apps/web-e2e/src/icon-button-names.e2e.ts +++ b/apps/web-e2e/src/icon-button-names.e2e.ts @@ -111,16 +111,12 @@ test('@a11y icon-only buttons are named in Sources and the playlist info dialog' }) => { await addM3uUrlPlaylist(page, M3U_TITLE); - // Sources list: refresh, details and remove on each row. + // Sources list: refresh, edit and the actions menu on each row. await openSources(page); const row = sourceRowByTitle(page, M3U_TITLE); await expect(row).toBeVisible({ timeout: 15_000 }); await row.hover(); - for (const name of [ - 'Refresh playlist', - 'Show playlist details', - 'Remove playlist', - ]) { + for (const name of ['Refresh playlist', 'Edit…', 'Playlist actions']) { await expect(row.getByRole('button', { name })).toBeAttached(); } await expectNamedButtons(page, 3); diff --git a/apps/web-e2e/src/player-settings-panel.fixture.ts b/apps/web-e2e/src/player-settings-panel.fixture.ts index f83995bba..898268107 100644 --- a/apps/web-e2e/src/player-settings-panel.fixture.ts +++ b/apps/web-e2e/src/player-settings-panel.fixture.ts @@ -80,7 +80,7 @@ export async function importPlaylist(page: Page): Promise { .getByRole('button', { name: 'Add playlist', exact: true }) .click(), ]); - await expect(page.getByText('1 channels')).toBeVisible(); + await expect(page.getByText('1 channel', { exact: false })).toBeVisible(); return page.url(); } diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index afa09de53..be4981db7 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -259,7 +259,7 @@ test('@self-hosted M3U User-Agent reaches the provider on import and refresh', a channel = 'Refreshed UA Channel'; const row = sourceRowByTitle(page, 'Protected M3U'); await row.hover(); - await row.locator('.refresh-btn').click(); + await row.locator('.source-row__refresh').click(); await expect( page.locator('.mat-mdc-snack-bar-label').last() ).toContainText('updated'); diff --git a/apps/web-e2e/src/sources-pwa.helpers.ts b/apps/web-e2e/src/sources-pwa.helpers.ts index ea393d357..848feef97 100644 --- a/apps/web-e2e/src/sources-pwa.helpers.ts +++ b/apps/web-e2e/src/sources-pwa.helpers.ts @@ -190,7 +190,10 @@ export async function openSources(page: Page): Promise { } export function sourceRowByTitle(page: Page, title: string): Locator { - return page.locator('app-playlist-item').filter({ hasText: title }).first(); + return page + .locator('app-workspace-sources app-source-row') + .filter({ hasText: title }) + .first(); } export async function openSourceEditor( @@ -200,7 +203,7 @@ export async function openSourceEditor( const row = sourceRowByTitle(page, title); await expect(row).toBeVisible({ timeout: 15_000 }); await row.hover(); - await row.locator('.edit-btn').click(); + await row.locator('.source-row__edit').click(); const dialog = page.locator('mat-dialog-container').last(); await expect(dialog).toBeVisible(); return dialog; diff --git a/apps/web-e2e/src/stalker-radio-player.fixture.ts b/apps/web-e2e/src/stalker-radio-player.fixture.ts new file mode 100644 index 000000000..58a0238ad --- /dev/null +++ b/apps/web-e2e/src/stalker-radio-player.fixture.ts @@ -0,0 +1,78 @@ +import type { Page } from '@playwright/test'; +import { pressTab } from './e2e-helpers'; +import { expect } from './fixtures'; + +/** The computed colour a custom property resolves to on this page. */ +async function computedColor(page: Page, property: string): Promise { + return page.evaluate((name) => { + const probe = document.createElement('span'); + probe.style.color = `var(${name})`; + document.body.append(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }, property); +} + +/** + * The radio player's play/pause control is a `mat-icon-button` drawn as the + * 56px primary circle the fab used to draw (the fab tokens alone were about + * 6 KB of the initial stylesheet), with the icon centred, and keyboard focus + * draws the app ring that the fab reset with `outline: none`. + */ +export async function verifyRadioPlayControl( + page: Page, + browserName: string +): Promise { + const player = page.locator('app-audio-player'); + const play = player.locator('.play-btn'); + await expect(play).toBeVisible(); + await expect(play).toHaveClass(/mat-mdc-icon-button/); + await expect(play).not.toHaveClass(/mat-mdc-fab/); + + const style = await play.evaluate((button) => { + const icon = button.querySelector('mat-icon') as HTMLElement; + const box = button.getBoundingClientRect(); + const iconBox = icon.getBoundingClientRect(); + const { width, height, borderRadius, backgroundColor } = + getComputedStyle(button); + const centre = (rect: DOMRect) => [ + rect.left + rect.width / 2, + rect.top + rect.height / 2, + ]; + const [x, y] = centre(box); + const [iconX, iconY] = centre(iconBox); + return { + width, + height, + borderRadius, + backgroundColor, + iconColor: getComputedStyle(icon).color, + iconFontSize: getComputedStyle(icon).fontSize, + iconOffset: [Math.round(iconX - x), Math.round(iconY - y)], + }; + }); + expect(style).toEqual({ + width: '56px', + height: '56px', + borderRadius: '50%', + backgroundColor: await computedColor(page, '--app-selection-color'), + iconColor: 'rgb(255, 255, 255)', + iconFontSize: '32px', + iconOffset: [0, 0], + }); + + await player.locator('.skip-btn').first().focus(); + await pressTab(page, browserName); + await expect(play).toBeFocused(); + const ring = await play.evaluate((button) => { + const { outlineStyle, outlineWidth, outlineColor } = + getComputedStyle(button); + return { outlineStyle, outlineWidth, outlineColor }; + }); + expect(ring).toEqual({ + outlineStyle: 'solid', + outlineWidth: '2px', + outlineColor: await computedColor(page, '--app-focus-ring'), + }); +} diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 1c744ef63..114ac09a1 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -18,6 +18,7 @@ import { import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture'; import { verifyStalkerOpenInPlaylist } from './stalker-open-in-playlist.fixture'; import { playFirstItvChannel } from './stalker-itv-playback.fixture'; +import { verifyRadioPlayControl } from './stalker-radio-player.fixture'; import { AUTH_REJECTED_MAC, BACKEND_PROXY, @@ -182,12 +183,15 @@ test('@stalker account info dialog shows subscription facts from the portal', as }) => { await addStalkerPortal(page); - // Open the header playlist switcher and its "Account info" entry for - // the active stalker portal. - await page.locator('.playlist-switcher-trigger').click(); + // Open the header playlist switcher and the "Account info" entry in the + // active stalker portal's row menu. + await page.locator('[data-test-id="playlist-switcher-trigger"]').click(); await page - .locator('.context-action-item', { hasText: 'Account info' }) + .locator( + 'app-playlist-switcher-panel [role="option"][aria-current="true"] .source-row__more' + ) .click(); + await page.getByRole('menuitem', { name: 'Account info' }).click(); const dialog = page.locator('mat-dialog-container'); await expect(dialog).toBeVisible(); @@ -293,6 +297,7 @@ test('@stalker ITV playback survives a category switch', async ({ page }) => { test('@stalker radio — stations use the inline audio player without EPG', async ({ page, + browserName, }) => { await addStalkerPortal(page); @@ -330,6 +335,7 @@ test('@stalker radio — stations use the inline audio player without EPG', asyn await expect(page.locator('app-audio-player')).toBeVisible({ timeout: 20_000, }); + await verifyRadioPlayControl(page, browserName); // Level 2: only the categories rail folds; the channels header turns // its title into a category dropdown that opens the same rail as a diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 193eba2d8..60bc3a554 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -61,18 +61,15 @@ async function openPlaylistDetailsDialog(page: Page, title: string) { await playlistSettingsButton.click(); } else { await page - .locator('app-playlist-switcher .playlist-switcher-trigger') + .locator('[data-test-id="playlist-switcher-trigger"]') .click(); - const switcherMenu = page.getByRole('menu').filter({ hasText: title }); - const sourceRow = switcherMenu - .locator('.playlist-item') + const sourceRow = page + .locator('app-playlist-switcher-panel [role="option"]') .filter({ hasText: title }) .first(); await expect(sourceRow).toBeVisible(); - await sourceRow - .getByRole('button', { name: 'Playlist actions' }) - .click(); + await sourceRow.locator('.source-row__more').click(); await page.getByRole('menuitem', { name: 'Playlist info' }).click(); } diff --git a/apps/web/project.json b/apps/web/project.json index 6de9984ff..bed6822c7 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -19,7 +19,6 @@ "base": "dist/apps/web", "browser": "" }, - "polyfills": ["zone.js"], "tsConfig": "apps/web/tsconfig.app.json", "inlineStyleLanguage": "scss", "serviceWorker": "ngsw-config.json", @@ -149,31 +148,6 @@ } ] }, - "electron-performance-zoneless": { - "baseHref": "./", - "serviceWorker": false, - "optimization": { - "scripts": true, - "styles": { - "minify": true, - "inlineCritical": false, - "removeSpecialComments": true - }, - "fonts": true - }, - "outputHashing": "all", - "sourceMap": true, - "fileReplacements": [ - { - "replace": "apps/web/src/environments/environment.ts", - "with": "apps/web/src/environments/environment.performance.ts" - }, - { - "replace": "apps/web/src/environments/change-detection.providers.ts", - "with": "apps/web/src/environments/change-detection.providers.zoneless.ts" - } - ] - }, "electron-e2e": { "baseHref": "./", "serviceWorker": false, @@ -186,23 +160,6 @@ "with": "apps/web/src/environments/store-devtools.providers.dev.ts" } ] - }, - "electron-e2e-zoneless": { - "baseHref": "./", - "serviceWorker": false, - "optimization": false, - "extractLicenses": false, - "sourceMap": true, - "fileReplacements": [ - { - "replace": "apps/web/src/environments/store-devtools.providers.ts", - "with": "apps/web/src/environments/store-devtools.providers.dev.ts" - }, - { - "replace": "apps/web/src/environments/change-detection.providers.ts", - "with": "apps/web/src/environments/change-detection.providers.zoneless.ts" - } - ] } }, "defaultConfiguration": "production" diff --git a/apps/web/src/app/app-startup-status.component.ts b/apps/web/src/app/app-startup-status.component.ts index 46375638c..ac99aae8b 100644 --- a/apps/web/src/app/app-startup-status.component.ts +++ b/apps/web/src/app/app-startup-status.component.ts @@ -6,7 +6,6 @@ import { input, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { Store } from '@ngrx/store'; import { TranslatePipe } from '@ngx-translate/core'; import { @@ -19,7 +18,7 @@ import { @Component({ selector: 'app-startup-status', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [MatButtonModule, MatProgressSpinnerModule, TranslatePipe], + imports: [MatButtonModule, TranslatePipe], template: ` @if (!complete()) {
@if (!failed()) { -