import type { Page } from '@playwright/test'; import { join } from 'path'; import { setSwitch } from './e2e-helpers'; import { expect, test } from './fixtures'; async function openSettings(page: Page) { await page.locator('a[href$="/workspace/settings"]').click(); // The bare settings URL redirects to the default section page. await page.waitForURL(/\/workspace\/settings\/general$/); await expect(page.locator('.settings-container')).toBeVisible(); await expect(settingsBack(page)).toBeVisible(); } /** Settings' Back is the workspace header's leading button. */ function settingsBack(page: Page) { return page.locator('[data-test-id="workspace-header-back"]'); } /** Settings render one section page at a time — open it via the rail. */ async function openSettingsSection(page: Page, sectionId: string) { await page .locator(`[data-test-id="settings-section-${sectionId}"]`) .click(); await page.waitForURL(new RegExp(`/workspace/settings/${sectionId}$`)); } async function saveSettings(page: Page) { const saveButton = page.locator('[data-test-id="save-settings"]'); await saveButton.click(); // A successful save marks the form pristine, which removes the whole // unsaved-changes bar together with the save button. await expect(saveButton).toBeHidden(); } test.describe('Settings', () => { test.beforeEach(async ({ page }) => { // Playwright creates a fresh browser context per test, so extra // IndexedDB cleanup here only risks racing with app-managed DB handles. await page.goto('/'); }); test('@settings @web Check settings page', async ({ page }) => { await openSettings(page); // The context panel no longer carries a Back of its own. await expect( page.locator('app-workspace-settings-context-panel button') ).toHaveCount(0); await settingsBack(page).click(); await page.waitForURL(/\/workspace\/dashboard$/); }); test('@settings @web settings opening the session lead to the dashboard', async ({ page, }) => { // The only entry of a new tab's history, as in Electron after a // deep link: browser Back has nowhere to go. const firstEntryPage = await page.context().newPage(); await firstEntryPage.goto('/workspace/settings/general'); // A cold start in a new tab passes the startup splash first. await expect(firstEntryPage.locator('.settings-container')).toBeVisible( { timeout: 15_000 } ); await settingsBack(firstEntryPage).click(); await firstEntryPage.waitForURL(/\/workspace\/dashboard$/); // The dashboard replaced the settings entry: no Back leads to it. await expect(settingsBack(firstEntryPage)).toHaveCount(0); }); test('@settings @web Change video player', async ({ page }) => { await openSettings(page); await openSettingsSection(page, 'playback'); const playerSelect = page.locator( '[data-test-id="select-video-player"]' ); await expect(playerSelect).toContainText(/Video\.js/i); await playerSelect.click(); await page.locator('mat-option[data-test-id="html5"]').click(); await saveSettings(page); await page.reload(); await openSettings(page); await openSettingsSection(page, 'playback'); await expect(playerSelect).toContainText(/HTML5/i); }); test('@settings @web Opt out of shared web player controls', async ({ page, }) => { await openSettings(page); await openSettingsSection(page, 'playback'); const setting = page.locator( '[data-test-id="web-player-shared-controls-setting"]' ); const checkbox = setting.getByRole('switch'); await expect(setting).toBeVisible(); // Shared controls default ON; the switch is the opt-out. await expect(checkbox).toBeChecked(); await setSwitch(checkbox, false); await saveSettings(page); await page.reload(); await openSettings(page); await openSettingsSection(page, 'playback'); await expect(checkbox).not.toBeChecked(); }); test('@settings @web Change app theme', async ({ page }) => { await openSettings(page); // v0.22 compact theme picker exposes the segmented control as a // radiogroup with options labelled just "Light"/"Dark"/"System". // Scope to the theme radiogroup so we don't collide with the // identically-labelled cover-size options below. const themeGroup = page.locator( '[data-test-id="select-theme"][role="radiogroup"]' ); await expect( themeGroup.getByRole('radio', { name: 'System', exact: true }) ).toHaveAttribute('aria-checked', 'true'); await themeGroup .getByRole('radio', { name: 'Dark', exact: true }) .click(); await saveSettings(page); await page.reload(); await openSettings(page); await expect( themeGroup.getByRole('radio', { name: 'Dark', exact: true }) ).toHaveAttribute('aria-checked', 'true'); }); test('@settings @web Deep links open one section page and unknown sections redirect', async ({ page, }) => { await page.goto('/workspace/settings/playback'); // Only the routed section renders — the playback controls are // there, the general ones are not. await expect( page.locator('[data-test-id="select-video-player"]') ).toBeVisible(); await expect( page.locator('[data-test-id="select-language"]') ).toHaveCount(0); // A stale or mistyped section URL is rewritten to the default page. await page.goto('/workspace/settings/nonsense'); await page.waitForURL(/\/workspace\/settings\/general$/); await expect( page.locator('[data-test-id="select-language"]') ).toBeVisible(); }); test('@settings @web Unsaved bar survives section switches and discard reverts', async ({ page, }) => { await openSettings(page); const unsavedBar = page.locator( '[data-test-id="settings-unsaved-bar"]' ); await expect(unsavedBar).toBeHidden(); const themeGroup = page.locator( '[data-test-id="select-theme"][role="radiogroup"]' ); await themeGroup .getByRole('radio', { name: 'Dark', exact: true }) .click(); await expect(unsavedBar).toBeVisible(); // The bar counts the staged change and the sidebar marks its page. await expect( unsavedBar.locator('[data-test-id="settings-unsaved-message"]') ).toHaveText('1 unsaved change'); await expect( page.locator('[data-test-id="settings-section-dirty-general"]') ).toBeVisible(); // The staged edit belongs to the page, not the section — moving to // another section page must keep the bar (and the pending change). await openSettingsSection(page, 'playback'); await expect(unsavedBar).toBeVisible(); await expect( page.locator('[data-test-id="settings-section-dirty-general"]') ).toBeVisible(); await expect( page.locator('[data-test-id="settings-section-dirty-playback"]') ).toHaveCount(0); await page.locator('[data-test-id="discard-settings"]').click(); await expect(unsavedBar).toBeHidden(); await expect( page.locator('[data-test-id="settings-section-dirty-general"]') ).toHaveCount(0); await openSettingsSection(page, 'general'); await expect( themeGroup.getByRole('radio', { name: 'System', exact: true }) ).toHaveAttribute('aria-checked', 'true'); }); test('@settings @web Ctrl+S saves the staged edits', async ({ page }) => { await openSettings(page); await page .locator('[data-test-id="select-theme"] [data-test-id="DARK_THEME"]') .click(); const unsavedBar = page.locator( '[data-test-id="settings-unsaved-bar"]' ); await expect(unsavedBar).toBeVisible(); await page.keyboard.press('Control+s'); await expect(unsavedBar).toBeHidden(); await page.reload(); await openSettings(page); await expect( page .locator('[data-test-id="select-theme"]') .getByRole('radio', { name: 'Dark', exact: true }) ).toHaveAttribute('aria-checked', 'true'); }); test('@settings @web About keeps its label and clears the window bottom', async ({ page, }) => { await openSettings(page); const about = page.locator( '.settings-nav__footer [data-test-id="settings-section-about"]' ); const version = about.locator('[data-test-id="settings-nav-version"]'); await expect(version).toBeVisible(); // A nightly build string is far wider than the default panel. await version.evaluate((node) => { node.textContent = '0.25.0-nightly.20261010.3937'; }); const layout = await about.evaluate((link) => { const label = link.querySelector('.nav-item-label') as HTMLElement; const tag = link.querySelector( '[data-test-id="settings-nav-version"]' ) as HTMLElement; const panel = link.closest('.context-panel') as HTMLElement; const linkBox = link.getBoundingClientRect(); return { labelClipped: label.scrollWidth > label.clientWidth, versionClipped: tag.scrollWidth > tag.clientWidth, linkOverflows: link.scrollWidth > link.clientWidth, linkBottom: linkBox.bottom, panelBottom: panel.getBoundingClientRect().bottom, viewportHeight: window.innerHeight, }; }); expect(layout.labelClipped).toBe(false); expect(layout.versionClipped).toBe(true); expect(layout.linkOverflows).toBe(false); // The panel ends at the window edge, and its bottom padding keeps the // footer item off it. expect(layout.panelBottom).toBeLessThanOrEqual(layout.viewportHeight); expect( layout.viewportHeight - layout.linkBottom ).toBeGreaterThanOrEqual(12); }); test('@settings @web The installed version shows while GitHub is unreachable', async ({ page, }) => { // Offline, or rate-limited by api.github.com: the release check never // answers, yet the installed version is known locally. await page.route('**/repos/4gray/iptvnator/releases*', (route) => route.abort() ); await openSettings(page); const version = /\d+\.\d+\.\d+/; await expect( page.locator( '[data-test-id="settings-section-about"] [data-test-id="settings-nav-version"]' ) ).toHaveText(version); await openSettingsSection(page, 'about'); await expect(page.locator('[data-test-id="app-version"]')).toHaveText( version ); }); test('@settings @web Esc leaves settings like the header Back', async ({ page, }) => { await openSettings(page); // The sidebar groups the pages and pins About to the footer. await expect( page.locator('.settings-nav__group[data-group="app"]') ).toContainText('General'); await expect( page.locator('.settings-nav__footer [data-test-id="settings-section-about"]') ).toBeVisible(); // The rail link's tooltip is still up after the click that opened // settings, and a visible tooltip consumes Escape: move on and wait // for it to finish closing first. await page.locator('.settings-group__header h2').click(); await expect(page.locator('.mat-mdc-tooltip')).toHaveCount(0); await page.keyboard.press('Escape'); await page.waitForURL(/\/workspace\/dashboard$/); }); test('@settings @web Leaving with unsaved edits asks for confirmation', async ({ page, }) => { await openSettings(page); const themeGroup = page.locator( '[data-test-id="select-theme"][role="radiogroup"]' ); await themeGroup .getByRole('radio', { name: 'Dark', exact: true }) .click(); // Trying to leave the settings area surfaces the dialog. await page .locator('app-workspace-shell-rail') .getByRole('link', { name: 'Dashboard', exact: true }) .click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); // Keep editing: navigation is cancelled, the edit survives. await dialog.locator('[data-test-id="unsaved-dialog-stay"]').click(); await expect(dialog).toBeHidden(); await expect(page).toHaveURL(/\/workspace\/settings\/general$/); await expect( page.locator('[data-test-id="settings-unsaved-bar"]') ).toBeVisible(); // Leave without saving: the staged edit is discarded. await page .locator('app-workspace-shell-rail') .getByRole('link', { name: 'Dashboard', exact: true }) .click(); await page .getByRole('dialog') .locator('[data-test-id="unsaved-dialog-discard"]') .click(); await page.waitForURL(/\/workspace\/dashboard$/); await openSettings(page); await expect( themeGroup.getByRole('radio', { name: 'System', exact: true }) ).toHaveAttribute('aria-checked', 'true'); }); test('@settings @web Save-and-leave persists the staged edit', async ({ page, }) => { await openSettings(page); const themeGroup = page.locator( '[data-test-id="select-theme"][role="radiogroup"]' ); await themeGroup .getByRole('radio', { name: 'Dark', exact: true }) .click(); await page .locator('app-workspace-shell-rail') .getByRole('link', { name: 'Dashboard', exact: true }) .click(); await page .getByRole('dialog') .locator('[data-test-id="unsaved-dialog-save"]') .click(); await page.waitForURL(/\/workspace\/dashboard$/); await page.reload(); await openSettings(page); await expect( themeGroup.getByRole('radio', { name: 'Dark', exact: true }) ).toHaveAttribute('aria-checked', 'true'); }); test('@settings @web Parental lock — set a PIN, lock, survive a reload, unlock', async ({ page, }) => { await openSettings(page); await openSettingsSection(page, 'parental'); const enableToggle = page.locator( '[data-test-id="parental-lock-enabled"] button[role="switch"]' ); const pinInput = page.locator('[data-test-id="parental-lock-pin"]'); const pinConfirm = page.locator( '[data-test-id="parental-lock-pin-confirm"]' ); const pinSubmit = page.locator( '[data-test-id="parental-lock-pin-submit"]' ); const lockNow = page.locator('[data-test-id="parental-lock-lock-now"]'); const unlock = page.locator('[data-test-id="parental-lock-unlock"]'); const headerLock = page.locator( '[data-test-id="header-parental-lock"]' ); // Enabling asks for a new PIN twice; the parent stays unlocked. await expect(enableToggle).toHaveAttribute('aria-checked', 'false'); await enableToggle.click(); await expect(pinInput).toBeVisible(); await pinInput.fill('2468'); await pinConfirm.fill('2468'); await pinSubmit.click(); await expect(enableToggle).toHaveAttribute('aria-checked', 'true'); await expect(lockNow).toBeVisible(); await expect(headerLock).toBeVisible(); // Lock now flips the state; a reload keeps the lock (never persisted // as unlocked). await lockNow.click(); await expect(unlock).toBeVisible(); await page.reload(); await openSettings(page); await openSettingsSection(page, 'parental'); await expect(enableToggle).toHaveAttribute('aria-checked', 'true'); await expect(unlock).toBeVisible(); // A wrong PIN is refused, the right one unlocks. await unlock.click(); await expect(pinInput).toBeVisible(); await pinInput.fill('0000'); await pinSubmit.click(); await expect( page.locator('[data-test-id="parental-lock-pin-error"]') ).toBeVisible(); await pinInput.fill('2468'); await pinSubmit.click(); await expect(lockNow).toBeVisible(); // The header button locks from anywhere. await headerLock.click(); await expect(unlock).toBeVisible(); }); test('@settings @web Change app language', async ({ page }) => { await openSettings(page); const languageSelect = page.locator('[data-test-id="select-language"]'); await expect(languageSelect).toContainText('English'); await languageSelect.click(); await page.locator('mat-option[data-test-id="de"]').click(); await saveSettings(page); await page.reload(); await openSettings(page); await expect(languageSelect).toContainText('Deutsch'); }); test('@settings @search @web Search settings from the header and open a result', async ({ page, }) => { await openSettings(page); // The fresh browser context has no playlists; settings search must // still be offered. const search = page.locator( 'app-workspace-shell-header input[type="search"]' ); await expect(search).toBeEnabled(); // "subtitles" is a keyword of the "Show subtitles" row, which lives // on the Playback page: the result leads there from General. await search.fill('subtitles'); await expect(page).toHaveURL(/\/workspace\/settings\/general\?q=subtitles$/); await expect( page.locator('[data-test-id="settings-search-results"]') ).toBeVisible(); await expect(page.locator('app-settings-general-section')).toHaveCount(0); await expect( page.locator('[data-test-id="settings-section-matches-playback"]') ).toHaveText('1'); await expect( page.locator('[data-test-id="settings-section-matches-general"]') ).toHaveText('0'); await page .locator('[data-test-id="settings-search-result-show-captions"]') .click(); await expect(page).toHaveURL(/\/workspace\/settings\/playback$/); await expect(search).toHaveValue(''); const row = page.locator('[data-setting-id="show-captions"]'); await expect(row).toBeFocused(); await expect(row).toHaveClass(/setting-item--revealed/); await expect(row).not.toHaveClass(/setting-item--revealed/, { timeout: 5000, }); }); test('@settings @search @web Enter opens the best settings match', async ({ page, }) => { await openSettings(page); const search = page.locator( 'app-workspace-shell-header input[type="search"]' ); await search.fill('stream format'); await search.press('Enter'); await expect(page).toHaveURL(/\/workspace\/settings\/playback$/); await expect( page.locator('[data-setting-id="stream-format"]') ).toBeFocused(); }); test('@settings @search @web Command palette opens a setting from anywhere', async ({ page, }) => { // The shell registers Ctrl/Cmd+K once its lazy chunk has rendered. await expect( page.locator('a[href$="/workspace/settings"]') ).toBeVisible(); await expect(page).not.toHaveURL(/\/workspace\/settings/); await page.keyboard.press('Control+k'); const palette = page.locator('.workspace-command-palette-overlay'); await expect(palette).toBeVisible(); await palette.locator('input[type="search"]').fill('ambient'); await expect(palette).toContainText('Settings'); await palette .locator('.palette-command', { hasText: /ambient/i }) .first() .click(); await expect(page).toHaveURL(/\/workspace\/settings\/playback$/); await expect( page.locator('[data-setting-id="player-ambient-mode"]') ).toBeFocused(); }); test.afterEach(async ({ page }, testInfo) => { await page.screenshot({ path: join( process.cwd(), 'dist/.playwright/apps/web-e2e/screenshots/settings', `${testInfo.title}.png` ), }); }); });