mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
* fix(a11y): accessible names for icon-only buttons and a guard
Icon-only buttons named only by a matTooltip (or by nothing) had no
accessible name: the icon ligature is hidden from assistive technology
and a tooltip only adds a description. 22 buttons on master, in .html
and inline templates, now carry a translated aria-label bound to their
tooltip key. The channel row's favorite star keeps one label
("Favorite") and reports its state through aria-pressed.
New guard `pnpm run a11y:icon-buttons:validate` (CI step) fails on a
<button> whose only content is mat-icons (through control flow,
ng-container and spinners) without aria-label, an aria-label binding
or aria-labelledby. The inline-template lookup moves to a shared
tools/nx/inline-templates.mjs used by the icon-ligature guard too.
web-e2e icon-button-names.e2e.ts runs axe's button-name rule on a
channel list, the channel details dialog, Sources, the playlist info
dialog and an Xtream search. Five new keys are translated in all 18
locales.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(a11y): text hidden from assistive technology never names an icon button
Greptile: a static aria-hidden="true" child's text counted as the
button's name, so <button><mat-icon/><span aria-hidden="true">Close</span>
passed the guard. Hidden subtrees now add only their icons.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
171 lines
6.1 KiB
TypeScript
171 lines
6.1 KiB
TypeScript
import type { Locator, Page } from '@playwright/test';
|
|
import { readFileSync } from 'node:fs';
|
|
import { expect, test } from './fixtures';
|
|
import { CLIP_TITLE, importPlaylist } from './player-settings-panel.fixture';
|
|
import {
|
|
addM3uUrlPlaylist,
|
|
addXtreamPortal,
|
|
interceptPwaProviderRequests,
|
|
openSourceEditor,
|
|
openSources,
|
|
resetPwaMockServers,
|
|
sourceRowByTitle,
|
|
XTREAM_MOCK_SERVER,
|
|
} from './sources-pwa.helpers';
|
|
|
|
/**
|
|
* Every icon-only button on the main surfaces has an accessible name: axe's
|
|
* `button-name` rule over the whole page on a channel list in the player
|
|
* sidebar, the channel details dialog, the Sources list, the playlist info
|
|
* dialog and an Xtream catalog search. `pnpm run a11y:icon-buttons:validate` checks the
|
|
* templates statically; this checks the rendered names, so a label bound to
|
|
* an empty value fails too.
|
|
*/
|
|
|
|
const AXE_SOURCE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
|
|
const M3U_TITLE = 'Icon Button Names M3U';
|
|
const XTREAM_TITLE = 'Icon Button Names Xtream';
|
|
|
|
interface ButtonNameAudit {
|
|
violations: string[];
|
|
checked: number;
|
|
}
|
|
|
|
/** Runs axe's `button-name` rule over `scope`. */
|
|
async function auditButtonNames(scope: Locator): Promise<ButtonNameAudit> {
|
|
await scope.page().evaluate(AXE_SOURCE);
|
|
return scope.evaluate(async (element) => {
|
|
const axe = (window as unknown as { axe: typeof import('axe-core') })
|
|
.axe;
|
|
const results = await axe.run(element, {
|
|
runOnly: { type: 'rule', values: ['button-name'] },
|
|
resultTypes: ['violations', 'passes'],
|
|
});
|
|
const nodes = (kind: 'violations' | 'passes') =>
|
|
results[kind].flatMap((rule) => rule.nodes);
|
|
return {
|
|
violations: nodes('violations').map(
|
|
(node) => `${node.target.join(' ')}: ${node.html}`
|
|
),
|
|
checked: nodes('violations').length + nodes('passes').length,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* No nameless button on the page (dialogs included), which shows at least
|
|
* `minimum` buttons.
|
|
*/
|
|
async function expectNamedButtons(page: Page, minimum: number) {
|
|
const audit = await auditButtonNames(page.locator('body'));
|
|
expect(audit.violations).toEqual([]);
|
|
expect(audit.checked).toBeGreaterThanOrEqual(minimum);
|
|
}
|
|
|
|
async function closeDialog(page: Page, dialog: Locator) {
|
|
await page.keyboard.press('Escape');
|
|
await expect(dialog).toBeHidden();
|
|
}
|
|
|
|
test.beforeEach(async ({ page, request }) => {
|
|
await resetPwaMockServers(request);
|
|
await interceptPwaProviderRequests(page);
|
|
await page.goto('/');
|
|
});
|
|
|
|
test('@a11y icon-only buttons are named in a channel list and its details', async ({
|
|
page,
|
|
}) => {
|
|
await importPlaylist(page);
|
|
|
|
// Channel list in the player sidebar: the favorite toggle and the row
|
|
// actions.
|
|
const channel = page
|
|
.locator('app-sidebar app-channel-list-item')
|
|
.filter({ hasText: CLIP_TITLE });
|
|
await expect(channel).toBeVisible();
|
|
await channel.hover();
|
|
await expectNamedButtons(page, 5);
|
|
|
|
// The favorite toggle keeps one name and reports its state.
|
|
const favorite = channel.locator('.favorite-button');
|
|
await expect(favorite).toHaveAccessibleName('Favorite');
|
|
await expect(favorite).toHaveAttribute('aria-pressed', 'false');
|
|
await favorite.click();
|
|
await expect(favorite).toHaveAttribute('aria-pressed', 'true');
|
|
await expect(favorite).toHaveAccessibleName('Favorite');
|
|
|
|
// Channel details dialog: the copy button next to the stream URL.
|
|
await channel.locator('.channel-content').click({ button: 'right' });
|
|
await page.getByRole('menuitem', { name: 'Show channel details' }).click();
|
|
const details = page.locator('mat-dialog-container').last();
|
|
await expect(
|
|
details.getByRole('button', { name: 'Copy stream URL' })
|
|
).toBeVisible();
|
|
await expectNamedButtons(page, 1);
|
|
await closeDialog(page, details);
|
|
});
|
|
|
|
test('@a11y icon-only buttons are named in Sources and the playlist info dialog', async ({
|
|
page,
|
|
}) => {
|
|
await addM3uUrlPlaylist(page, M3U_TITLE);
|
|
|
|
// Sources list: refresh, details and remove 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',
|
|
]) {
|
|
await expect(row.getByRole('button', { name })).toBeAttached();
|
|
}
|
|
await expectNamedButtons(page, 3);
|
|
|
|
// Playlist info dialog, with an EPG source row and its remove button.
|
|
const info = await openSourceEditor(page, M3U_TITLE);
|
|
await info.getByRole('button', { name: 'Add EPG source' }).click();
|
|
await expect(
|
|
info.getByRole('button', { name: 'Remove EPG source' }).last()
|
|
).toBeVisible();
|
|
await expect(
|
|
info.getByRole('button', { name: 'Copy URL to clipboard' })
|
|
).toBeVisible();
|
|
await expectNamedButtons(page, 3);
|
|
await closeDialog(page, info);
|
|
});
|
|
|
|
test('@a11y icon-only buttons are named in an Xtream catalog search', async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
await addXtreamPortal(page, XTREAM_TITLE);
|
|
|
|
// Search for a word from a movie the portal serves, so the results grid
|
|
// renders cards. The PWA searches the catalog it holds in memory, so stay
|
|
// in the app instead of reloading into the search route.
|
|
const response = await request.get(
|
|
`${XTREAM_MOCK_SERVER}/player_api.php?username=user1&password=pass1&action=get_vod_streams`
|
|
);
|
|
expect(response.ok()).toBeTruthy();
|
|
const [movie] = (await response.json()) as { name: string }[];
|
|
const term = movie.name
|
|
.split(/[^\p{L}\p{N}]+/u)
|
|
.reduce((longest, word) =>
|
|
word.length > longest.length ? word : longest
|
|
);
|
|
|
|
await page.getByRole('link', { name: 'Advanced search' }).click();
|
|
await page.waitForURL(/\/search/);
|
|
const searchBox = page.getByRole('searchbox', { name: 'Search' });
|
|
await searchBox.fill(term);
|
|
await searchBox.press('Enter');
|
|
await expect(
|
|
page.locator('app-search-results app-content-card').first()
|
|
).toBeVisible({ timeout: 15_000 });
|
|
await expectNamedButtons(page, 1);
|
|
});
|