mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
The settings facade filled its version signal only inside the GitHub releases callback, so offline or under an api.github.com rate limit the installed version was missing from the sidebar's About item and the About page. The version is known locally from DataService, so the signal starts with it. The release check also gets an error handler: SettingsService already logs the failure, and without it the error resurfaced uncaught. The sidebar layout E2E no longer needs to stub the releases request; a new E2E aborts it and expects the version in both places. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
560 lines
21 KiB
TypeScript
560 lines
21 KiB
TypeScript
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`
|
|
),
|
|
});
|
|
});
|
|
});
|