mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(parental-lock): focus, tokens and phone width for lock UI (#1761)
This commit is contained in:
1 parent
03dfafd68b
commit
dc3f829807
20 files changed
+826
-121
No files matched your search
@@ -0,0 +1,412 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import { join } from 'path';
|
||||
import { expect, test } from './fixtures';
|
||||
import { pressTab, surfaceContrast } from './e2e-helpers';
|
||||
import {
|
||||
addStalkerPortal,
|
||||
addXtreamPortal,
|
||||
interceptPwaProviderRequests,
|
||||
resetPwaMockServers,
|
||||
} from './sources-pwa.helpers';
|
||||
|
||||
/**
|
||||
* Parental lock UI on app tokens (PWA).
|
||||
*
|
||||
* The lock rows, dialogs and PIN error once used Material system colours the
|
||||
* theme never declares, so every one of them resolved to nothing: no focus
|
||||
* ring after the outline reset, a dashed border and list border with no
|
||||
* colour, a PIN error in body text, and dialogs 440–480px wide on a 375px
|
||||
* phone. These tests pin the visible result in both themes:
|
||||
*
|
||||
* 1. Tabbing to each "N locked" row and each Stalker lock row shows a
|
||||
* 2px ring with >= 3:1 contrast against the row and its surface.
|
||||
* 2. The rail rows' dashed border and the lock list's border are painted.
|
||||
* 3. The PIN error is a red with >= 4.5:1 contrast on the dialog.
|
||||
* 4. The lock dialogs fit a 375px viewport without sideways overflow.
|
||||
* 5. In the PWA the Xtream dialog shows lock controls only — hide/show is
|
||||
* Electron-only — and its clear-search button has a name.
|
||||
*
|
||||
* Tag: @parental — run only this spec with:
|
||||
* pnpm nx run web-e2e:e2e-ci--src/parental-lock-ui.e2e.ts
|
||||
*/
|
||||
|
||||
const M3U_FIXTURE = join(__dirname, 'fixtures/test.m3u');
|
||||
const PIN = '2468';
|
||||
const PHONE = { width: 375, height: 812 };
|
||||
const THEMES = ['light', 'dark'] as const;
|
||||
|
||||
async function setTheme(page: Page, theme: (typeof THEMES)[number]) {
|
||||
await page.evaluate(
|
||||
(dark) => document.body.classList.toggle('dark-theme', dark),
|
||||
theme === 'dark'
|
||||
);
|
||||
}
|
||||
|
||||
async function enableParentalLock(page: Page): Promise<void> {
|
||||
await page.locator('a[href$="/workspace/settings"]').click();
|
||||
await page.waitForURL(/\/workspace\/settings\/general$/);
|
||||
await page.locator('[data-test-id="settings-section-parental"]').click();
|
||||
await page.waitForURL(/\/workspace\/settings\/parental$/);
|
||||
|
||||
const toggle = page.locator(
|
||||
'[data-test-id="parental-lock-enabled"] button[role="switch"]'
|
||||
);
|
||||
await toggle.click();
|
||||
await page.locator('[data-test-id="parental-lock-pin"]').fill(PIN);
|
||||
await page.locator('[data-test-id="parental-lock-pin-confirm"]').fill(PIN);
|
||||
await page.locator('[data-test-id="parental-lock-pin-submit"]').click();
|
||||
await expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
}
|
||||
|
||||
/** Answers the unlock prompt that a PIN-gated action opens. */
|
||||
async function enterPin(page: Page, pin = PIN): Promise<void> {
|
||||
const input = page.locator('[data-test-id="parental-lock-pin"]');
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(pin);
|
||||
await page.locator('[data-test-id="parental-lock-pin-submit"]').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* The dialog opened for `component`, once it holds focus: MatDialog moves
|
||||
* focus to its first tabbable element only after the open animation, which
|
||||
* would otherwise pull focus away from a row the test has just tabbed to.
|
||||
*/
|
||||
async function openedDialog(page: Page, component: string): Promise<Locator> {
|
||||
const dialog = page.locator('mat-dialog-container', {
|
||||
has: page.locator(component),
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
dialog.evaluate((el) => el.contains(document.activeElement))
|
||||
)
|
||||
.toBe(true);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/**
|
||||
* WCAG contrast of one colour property against what is painted behind the
|
||||
* element: white, each ancestor's background and, with `ownFill`, the
|
||||
* element's own background (the approach of `surfaceContrast`).
|
||||
*/
|
||||
async function contrastBehind(
|
||||
locator: Locator,
|
||||
property: 'color' | 'outlineColor',
|
||||
ownFill: boolean
|
||||
): Promise<number> {
|
||||
return locator.evaluate(
|
||||
(element, [property, ownFill]) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = 1;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Canvas is required');
|
||||
const paint = (color: string) => {
|
||||
context.fillStyle = color;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
};
|
||||
const luminance = () =>
|
||||
Array.from(context.getImageData(0, 0, 1, 1).data)
|
||||
.slice(0, 3)
|
||||
.map((value) => {
|
||||
const channel = value / 255;
|
||||
return channel <= 0.04045
|
||||
? channel / 12.92
|
||||
: ((channel + 0.055) / 1.055) ** 2.4;
|
||||
})
|
||||
.reduce(
|
||||
(sum, channel, index) =>
|
||||
sum + channel * [0.2126, 0.7152, 0.0722][index],
|
||||
0
|
||||
);
|
||||
const layers: Element[] = ownFill ? [element] : [];
|
||||
for (let e = element.parentElement; e; e = e.parentElement) {
|
||||
layers.unshift(e);
|
||||
}
|
||||
paint('white');
|
||||
layers.forEach((layer) =>
|
||||
paint(getComputedStyle(layer).backgroundColor)
|
||||
);
|
||||
const behind = luminance();
|
||||
paint(getComputedStyle(element)[property]);
|
||||
const front = luminance();
|
||||
return (
|
||||
(Math.max(front, behind) + 0.05) /
|
||||
(Math.min(front, behind) + 0.05)
|
||||
);
|
||||
},
|
||||
[property, ownFill] as const
|
||||
);
|
||||
}
|
||||
|
||||
/** Focus `from` and Tab (as that browser's user does) until `target` has focus. */
|
||||
async function tabTo(
|
||||
page: Page,
|
||||
browserName: string,
|
||||
from: Locator,
|
||||
target: Locator,
|
||||
maxTabs = 1
|
||||
): Promise<void> {
|
||||
await from.focus();
|
||||
for (let i = 0; i < maxTabs; i++) {
|
||||
await pressTab(page, browserName);
|
||||
if (await target.evaluate((el) => el === document.activeElement)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
await expect(target).toBeFocused();
|
||||
}
|
||||
|
||||
/**
|
||||
* The ring sits inside the row (offset -2px), so it must stand out from the
|
||||
* row's own fill and from the surface around the row.
|
||||
*/
|
||||
async function expectFocusRing(row: Locator): Promise<void> {
|
||||
await expect(row).toBeFocused();
|
||||
await expect(row).toHaveCSS('outline-style', 'solid');
|
||||
await expect(row).toHaveCSS('outline-width', '2px');
|
||||
const inside = await contrastBehind(row, 'outlineColor', true);
|
||||
const outside = await contrastBehind(row, 'outlineColor', false);
|
||||
expect(Math.min(inside, outside)).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
|
||||
/** Colour and fill of a lock toggle, to tell locked from unlocked. */
|
||||
async function toggleLook(toggle: Locator): Promise<string> {
|
||||
return toggle.evaluate((el) => {
|
||||
const style = getComputedStyle(el);
|
||||
return `${style.color} ${style.backgroundColor}`;
|
||||
});
|
||||
}
|
||||
|
||||
async function expectPaintedBorder(
|
||||
locator: Locator,
|
||||
style: 'dashed' | 'solid'
|
||||
): Promise<void> {
|
||||
await expect(locator).toHaveCSS('border-top-style', style);
|
||||
const { border } = await surfaceContrast(locator);
|
||||
// 1 means the border colour is transparent against its own fill.
|
||||
expect(border).toBeGreaterThan(1.15);
|
||||
}
|
||||
|
||||
async function expectDialogFitsPhone(
|
||||
page: Page,
|
||||
dialog: Locator
|
||||
): Promise<void> {
|
||||
await page.setViewportSize(PHONE);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await dialog.boundingBox();
|
||||
return box ? box.x >= 0 && box.x + box.width <= PHONE.width : false;
|
||||
})
|
||||
.toBe(true);
|
||||
// A min-width on the content overflows the capped panel without
|
||||
// widening the panel or the document, so measure the blocks inside.
|
||||
const overflow = await dialog.evaluate((panel) => {
|
||||
const edge = panel.getBoundingClientRect().right;
|
||||
const blocks = panel.querySelectorAll(
|
||||
'mat-dialog-content, mat-dialog-actions, .header-actions, ' +
|
||||
'.search-inline, .categories-list'
|
||||
);
|
||||
return Math.max(
|
||||
...Array.from(
|
||||
blocks,
|
||||
(block) => block.getBoundingClientRect().right - edge
|
||||
)
|
||||
);
|
||||
});
|
||||
expect(overflow).toBeLessThanOrEqual(0.5);
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth)
|
||||
).toBeLessThanOrEqual(PHONE.width);
|
||||
}
|
||||
|
||||
test.describe('Parental lock UI', () => {
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
await resetPwaMockServers(request);
|
||||
await interceptPwaProviderRequests(page);
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('@parental @xtream PWA dialog, locked-categories row and PIN error', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
test.slow();
|
||||
await addXtreamPortal(page, 'Lock UI Portal');
|
||||
const portalUrl = page.url();
|
||||
await enableParentalLock(page);
|
||||
// A reload starts locked: the unlock lives in memory only.
|
||||
await page.goto(portalUrl);
|
||||
|
||||
const manage = page.locator(
|
||||
'[data-test-id="context-manage-categories"]'
|
||||
);
|
||||
await expect(manage).toBeEnabled({ timeout: 15_000 });
|
||||
await manage.click();
|
||||
await enterPin(page);
|
||||
|
||||
const dialog = await openedDialog(
|
||||
page,
|
||||
'app-category-management-dialog'
|
||||
);
|
||||
const toggles = dialog.locator('[data-test-id^="category-lock-"]');
|
||||
await expect(toggles.first()).toBeVisible();
|
||||
|
||||
// PWA: locks only, no hide/show selection controls.
|
||||
await expect(
|
||||
dialog.locator('[data-test-id="category-locked-count"]')
|
||||
).toBeVisible();
|
||||
await expect(dialog.locator('.bulk-actions')).toHaveCount(0);
|
||||
await expect(dialog.locator('mat-checkbox')).toHaveCount(0);
|
||||
|
||||
await dialog.locator('.search-inline input').fill('a');
|
||||
await dialog.getByRole('button', { name: 'Clear search' }).click();
|
||||
await expect(dialog.locator('.search-inline input')).toHaveValue('');
|
||||
|
||||
// Locked reads as a state, not only as another glyph.
|
||||
await toggles.first().click();
|
||||
await expect(toggles.first()).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(await toggleLook(toggles.first())).not.toBe(
|
||||
await toggleLook(toggles.nth(1))
|
||||
);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await page.locator('[data-test-id="header-parental-lock"]').click();
|
||||
|
||||
const lockedRow = page.locator(
|
||||
'[data-test-id="context-locked-categories"]'
|
||||
);
|
||||
await expect(lockedRow).toBeVisible();
|
||||
await tabTo(
|
||||
page,
|
||||
browserName,
|
||||
page.locator('.context-panel .category-item').last(),
|
||||
lockedRow
|
||||
);
|
||||
for (const theme of THEMES) {
|
||||
await setTheme(page, theme);
|
||||
await expectFocusRing(lockedRow);
|
||||
await expectPaintedBorder(lockedRow, 'dashed');
|
||||
}
|
||||
|
||||
// A wrong PIN reads as an error in both themes.
|
||||
await lockedRow.click();
|
||||
await enterPin(page, '0000');
|
||||
const error = page.locator('[data-test-id="parental-lock-pin-error"]');
|
||||
await expect(error).toBeVisible();
|
||||
for (const theme of THEMES) {
|
||||
await setTheme(page, theme);
|
||||
const [r, g, b] = await error.evaluate((el) =>
|
||||
(getComputedStyle(el).color.match(/\d+/g) ?? []).map(Number)
|
||||
);
|
||||
expect(r - Math.max(g, b)).toBeGreaterThan(50);
|
||||
expect(await contrastBehind(error, 'color', true)).toBeGreaterThan(
|
||||
4.5
|
||||
);
|
||||
}
|
||||
await enterPin(page);
|
||||
await expect(lockedRow).toBeHidden();
|
||||
|
||||
await manage.click();
|
||||
await expect(toggles.first()).toBeVisible();
|
||||
await expectDialogFitsPhone(page, dialog);
|
||||
});
|
||||
|
||||
test('@parental @stalker lock dialog rows, list border and phone width', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
test.slow();
|
||||
await addStalkerPortal(page, 'Lock UI Stalker');
|
||||
const portalUrl = page.url();
|
||||
await enableParentalLock(page);
|
||||
await page.goto(portalUrl);
|
||||
|
||||
const manage = page.locator(
|
||||
'[data-test-id="context-manage-categories"]'
|
||||
);
|
||||
await expect(manage).toBeEnabled({ timeout: 15_000 });
|
||||
await manage.click();
|
||||
await enterPin(page);
|
||||
|
||||
const dialog = await openedDialog(
|
||||
page,
|
||||
'app-stalker-category-lock-dialog'
|
||||
);
|
||||
const rows = dialog.locator('.category-item');
|
||||
await expect(rows.first()).toBeVisible();
|
||||
|
||||
const search = dialog.locator('.search-inline input');
|
||||
await tabTo(page, browserName, search, rows.first());
|
||||
for (const theme of THEMES) {
|
||||
await setTheme(page, theme);
|
||||
await expectFocusRing(rows.first());
|
||||
await expectPaintedBorder(
|
||||
dialog.locator('.categories-list'),
|
||||
'solid'
|
||||
);
|
||||
}
|
||||
// The next row takes the ring over when Tab moves on.
|
||||
await pressTab(page, browserName);
|
||||
await expectFocusRing(rows.nth(1));
|
||||
|
||||
await search.fill('a');
|
||||
await dialog.getByRole('button', { name: 'Clear search' }).click();
|
||||
await expect(search).toHaveValue('');
|
||||
|
||||
await expectDialogFitsPhone(page, dialog);
|
||||
});
|
||||
|
||||
test('@parental @m3u locked-groups row', async ({ page, browserName }) => {
|
||||
test.slow();
|
||||
// The fixture's placeholder media hosts stay offline.
|
||||
await page.route(
|
||||
/^https?:\/\/(?:channel\.icons\.url|example\.channels|xml-url)\//,
|
||||
(route) => route.abort()
|
||||
);
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const addDialog = page.locator('mat-dialog-container');
|
||||
await addDialog.getByRole('radio', { name: /M3U file/i }).click();
|
||||
await page.setInputFiles('input[type="file"]', M3U_FIXTURE);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
|
||||
addDialog
|
||||
.getByRole('button', { name: 'Add playlist', exact: true })
|
||||
.click(),
|
||||
]);
|
||||
const groupsUrl = page.url().replace(/\/all$/, '/groups');
|
||||
await enableParentalLock(page);
|
||||
await page.goto(groupsUrl);
|
||||
|
||||
const manage = page.locator('.groups-nav-action--manage');
|
||||
await expect(manage).toBeVisible({ timeout: 15_000 });
|
||||
await manage.click();
|
||||
await enterPin(page);
|
||||
|
||||
const dialog = await openedDialog(page, 'app-group-management-dialog');
|
||||
const toggle = dialog.locator('[data-test-id="group-lock-News"]');
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(await toggleLook(toggle)).not.toBe(
|
||||
await toggleLook(
|
||||
dialog.locator('[data-test-id="group-lock-Sport"]')
|
||||
)
|
||||
);
|
||||
await dialog.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await page.locator('[data-test-id="header-parental-lock"]').click();
|
||||
|
||||
const lockedRow = page.locator('[data-test-id="groups-locked-row"]');
|
||||
await expect(lockedRow).toBeVisible();
|
||||
await tabTo(
|
||||
page,
|
||||
browserName,
|
||||
page.locator('.groups-nav-list .nav-item').last(),
|
||||
lockedRow
|
||||
);
|
||||
for (const theme of THEMES) {
|
||||
await setTheme(page, theme);
|
||||
await expectFocusRing(lockedRow);
|
||||
await expectPaintedBorder(lockedRow, 'dashed');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user