mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
The refine menu held 12 rows at 48px plus two section titles (657px). Angular Material 22 sets no max-height on a menu panel, so below ~800px the overlay was pushed up over its own Refine button, and at 600px the last thresholds were clipped behind an invisible scrollbar. The minimum-rating thresholds now open in a submenu from one row that names the active threshold. The menu is about 390px and opens below its button at 600px. The submenu trigger sits in a role="none" wrapper: Material offsets a submenu by the first direct mat-menu-item's offsetTop, and with the sort rows inside a role="group" that item was the trigger itself, which opened the submenu 335px off screen. The Electron E2E opens both menus by keyboard in a 600px-tall window and checks that each lies whole on screen without scrolling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
642 lines
25 KiB
TypeScript
642 lines
25 KiB
TypeScript
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
|
import {
|
|
addXtreamPortal,
|
|
channelItemByTitle,
|
|
clickCategoryByNameExact,
|
|
clickFirstGridListCard,
|
|
closeElectronApp,
|
|
defaultXtreamPassword,
|
|
defaultXtreamUsername,
|
|
expect,
|
|
launchElectronApp,
|
|
openSettings,
|
|
openSettingsSection,
|
|
openSources,
|
|
openWorkspaceSection,
|
|
resetMockServers,
|
|
saveSettings,
|
|
test,
|
|
waitForXtreamWorkspaceReady,
|
|
} from './electron-test-fixtures';
|
|
import { fetchXtreamLiveFixture, getXtreamTitle } from './portal-mock-fixtures';
|
|
import { applyTheme } from './theme-contrast';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Keyboard focus is visible everywhere. The global stylesheet used to strip
|
|
// the outline from every input, button and `:focus`; it now draws a fallback
|
|
// ring on `:focus-visible`, and a component with an indicator of its own
|
|
// keeps it. Every Tab stop below must show exactly one ring, on itself or on
|
|
// the ancestor or sibling that draws it for it (a content card, a checkbox,
|
|
// a select's form-field outline), in both themes, at 3:1 against what it
|
|
// sits on where that is a flat colour, and not cut by an ancestor's
|
|
// `overflow: hidden`. A mouse click leaves no ring.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type Ring = {
|
|
kind: 'outline' | 'box-shadow' | 'field-outline';
|
|
on: string;
|
|
color: string;
|
|
/** Null where the ring sits on artwork or a gradient. */
|
|
contrast: number | null;
|
|
/** The `overflow: hidden` ancestor that cuts the outline, if any. */
|
|
clippedBy: string | null;
|
|
/** `--app-focus-ring` where the ring is drawn. */
|
|
token: string;
|
|
};
|
|
|
|
type Stop = { element: string; label: string; rings: Ring[] };
|
|
|
|
type Probe = {
|
|
capture(): { element: string; label: string };
|
|
settle(): Ring[];
|
|
clicked(): { element: string; focusVisible: boolean; outline: string };
|
|
};
|
|
|
|
declare global {
|
|
interface Window {
|
|
__focusRingProbe?: Probe;
|
|
}
|
|
}
|
|
|
|
/** Calls one method of the probe installed in the page. */
|
|
function callProbe<M extends keyof Probe>(
|
|
page: Page,
|
|
method: M
|
|
): Promise<ReturnType<Probe[M]>> {
|
|
return page.evaluate((name) => {
|
|
const probe = window.__focusRingProbe;
|
|
if (!probe) throw new Error('The focus ring probe is not installed.');
|
|
return probe[name]() as ReturnType<Probe[M]>;
|
|
}, method);
|
|
}
|
|
|
|
/**
|
|
* Installs the in-page probe. `capture` snapshots the rings around the
|
|
* focused element; `settle`, run once focus has moved on, reports the rings
|
|
* that went away with it, i.e. the ones that focus drew.
|
|
*/
|
|
async function installProbe(page: Page): Promise<void> {
|
|
await page.evaluate(() => {
|
|
if (window.__focusRingProbe) return;
|
|
const canvas = document.createElement('canvas').getContext('2d', {
|
|
willReadFrequently: true,
|
|
}) as CanvasRenderingContext2D;
|
|
const transparent = (color: string) =>
|
|
color === 'transparent' || /,\s*0\)$/.test(color);
|
|
const ringOf = (el: Element) => {
|
|
const style = getComputedStyle(el);
|
|
const outline =
|
|
style.outlineStyle !== 'none' &&
|
|
parseFloat(style.outlineWidth) > 0 &&
|
|
!transparent(style.outlineColor)
|
|
? style.outlineColor
|
|
: '';
|
|
// A focus ring or line drawn as an unblurred shadow: a spread
|
|
// ring (`<color> 0 0 0 2px`) or an offset line, inset or not.
|
|
const line =
|
|
/(rgba?\([^)]*\)) (-?[\d.]+)px (-?[\d.]+)px 0px(?: (-?[\d.]+)px)?( inset)?/.exec(
|
|
style.boxShadow
|
|
);
|
|
const shadow =
|
|
line &&
|
|
[line[2], line[3], line[4] ?? '0'].some(
|
|
(length) => parseFloat(length) !== 0
|
|
)
|
|
? line[0]
|
|
: '';
|
|
return {
|
|
outline,
|
|
offset: parseFloat(style.outlineOffset) || 0,
|
|
shadow,
|
|
// A Material form field shows focus on its outline's border.
|
|
border: el.classList.contains('mdc-notched-outline__leading')
|
|
? `${style.borderTopWidth} ${style.borderTopColor}`
|
|
: '',
|
|
};
|
|
};
|
|
const name = (el: Element) =>
|
|
el.tagName.toLowerCase() +
|
|
[...el.classList]
|
|
.filter((c) => !c.startsWith('ng-') && !c.startsWith('cdk-'))
|
|
.slice(0, 2)
|
|
.map((c) => `.${c}`)
|
|
.join('');
|
|
/** Luminance of `colors` painted in order over white. */
|
|
const paint = (colors: string[]) => {
|
|
canvas.fillStyle = '#fff';
|
|
canvas.fillRect(0, 0, 1, 1);
|
|
for (const color of colors) {
|
|
canvas.fillStyle = color;
|
|
canvas.fillRect(0, 0, 1, 1);
|
|
}
|
|
const [r, g, b] = Array.from(
|
|
canvas.getImageData(0, 0, 1, 1).data.slice(0, 3)
|
|
).map((channel) => {
|
|
const c = channel / 255;
|
|
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
});
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
};
|
|
/** Background layers from the root down to `el`, or null on art. */
|
|
const layersUnder = (el: Element | null) => {
|
|
const layers: string[] = [];
|
|
for (let node = el; node; node = node.parentElement) {
|
|
const style = getComputedStyle(node);
|
|
if (style.backgroundImage !== 'none') return null;
|
|
layers.unshift(style.backgroundColor);
|
|
}
|
|
return layers;
|
|
};
|
|
/**
|
|
* The ancestor whose `overflow: hidden` cuts `el`'s outline, looking
|
|
* up to the nearest scroll container: past it, whether a ring at the
|
|
* edge shows depends on the scroll position, not on the styles.
|
|
*/
|
|
const clipperOf = (el: Element) => {
|
|
const style = getComputedStyle(el);
|
|
if (style.outlineStyle === 'none') return null;
|
|
const reach = Math.max(
|
|
0,
|
|
(parseFloat(style.outlineOffset) || 0) +
|
|
parseFloat(style.outlineWidth)
|
|
);
|
|
const box = el.getBoundingClientRect();
|
|
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
const clip = getComputedStyle(node);
|
|
const axes = [clip.overflowX, clip.overflowY];
|
|
if (axes.some((axis) => axis === 'auto' || axis === 'scroll')) {
|
|
return null;
|
|
}
|
|
if (
|
|
!axes.some((axis) => axis === 'hidden' || axis === 'clip')
|
|
) {
|
|
continue;
|
|
}
|
|
const edge = node.getBoundingClientRect();
|
|
const inset = (side: string) =>
|
|
parseFloat(clip.getPropertyValue(`border-${side}-width`)) -
|
|
0.5;
|
|
if (
|
|
box.left - reach < edge.left + inset('left') ||
|
|
box.top - reach < edge.top + inset('top') ||
|
|
box.right + reach > edge.right - inset('right') ||
|
|
box.bottom + reach > edge.bottom - inset('bottom')
|
|
) {
|
|
return name(node);
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
/** The app ring colour in `el`'s context, as `rgb(…)`. */
|
|
const tokenAt = (el: Element) => {
|
|
const hex = getComputedStyle(el)
|
|
.getPropertyValue('--app-focus-ring')
|
|
.trim();
|
|
canvas.fillStyle = '#000';
|
|
canvas.fillStyle = hex;
|
|
const [r, g, b] = Array.from(
|
|
(() => {
|
|
canvas.fillRect(0, 0, 1, 1);
|
|
return canvas.getImageData(0, 0, 1, 1).data;
|
|
})()
|
|
);
|
|
return hex ? `rgb(${r}, ${g}, ${b})` : '';
|
|
};
|
|
const contrastOf = (on: Element, color: string, inside: boolean) => {
|
|
const layers = layersUnder(inside ? on : on.parentElement);
|
|
if (!layers) return null;
|
|
const back = paint(layers);
|
|
const ring = paint([...layers, color]);
|
|
return (
|
|
(Math.max(back, ring) + 0.05) / (Math.min(back, ring) + 0.05)
|
|
);
|
|
};
|
|
|
|
let pending: {
|
|
candidates: Element[];
|
|
before: (ReturnType<typeof ringOf> & {
|
|
clippedBy: string | null;
|
|
})[];
|
|
} | null = null;
|
|
|
|
window.__focusRingProbe = {
|
|
capture() {
|
|
const el = document.activeElement ?? document.body;
|
|
const candidates = [el];
|
|
for (
|
|
let node = el.parentElement, depth = 0;
|
|
node && depth < 3;
|
|
node = node.parentElement, depth++
|
|
) {
|
|
candidates.push(node);
|
|
}
|
|
for (const sibling of el.parentElement?.children ?? []) {
|
|
if (sibling !== el) candidates.push(sibling);
|
|
}
|
|
const fieldOutline = el
|
|
.closest('.mat-mdc-form-field')
|
|
?.querySelector('.mdc-notched-outline__leading');
|
|
if (fieldOutline) candidates.push(fieldOutline);
|
|
// Geometry is read while focused: moving on may scroll.
|
|
pending = {
|
|
candidates,
|
|
before: candidates.map((candidate) => ({
|
|
...ringOf(candidate),
|
|
clippedBy: clipperOf(candidate),
|
|
})),
|
|
};
|
|
return {
|
|
element: name(el),
|
|
label: (
|
|
el.getAttribute('aria-label') ??
|
|
el.textContent ??
|
|
''
|
|
)
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.slice(0, 40),
|
|
};
|
|
},
|
|
settle() {
|
|
if (!pending) return [];
|
|
const { candidates, before } = pending;
|
|
pending = null;
|
|
return candidates.flatMap((on, index) => {
|
|
const was = before[index];
|
|
const now = ringOf(on);
|
|
const rings: Ring[] = [];
|
|
if (was.outline && was.outline !== now.outline) {
|
|
rings.push({
|
|
kind: 'outline',
|
|
on: name(on),
|
|
color: was.outline,
|
|
contrast: contrastOf(
|
|
on,
|
|
was.outline,
|
|
was.offset < 0
|
|
),
|
|
clippedBy: was.clippedBy,
|
|
token: tokenAt(on),
|
|
});
|
|
}
|
|
if (was.shadow && was.shadow !== now.shadow) {
|
|
const color = was.shadow.replace(/\) .*$/, ')');
|
|
rings.push({
|
|
kind: 'box-shadow',
|
|
on: name(on),
|
|
color,
|
|
contrast: contrastOf(
|
|
on,
|
|
color,
|
|
was.shadow.endsWith(' inset')
|
|
),
|
|
clippedBy: null,
|
|
token: tokenAt(on),
|
|
});
|
|
}
|
|
if (was.border && was.border !== now.border) {
|
|
const color = getComputedStyle(on).borderTopColor;
|
|
const focused = was.border.replace(/^\S+ /, '');
|
|
rings.push({
|
|
kind: 'field-outline',
|
|
on: name(on),
|
|
color: focused,
|
|
// Against the field's own fill, inside the line.
|
|
contrast:
|
|
focused === color
|
|
? null
|
|
: contrastOf(on, focused, true),
|
|
clippedBy: null,
|
|
token: tokenAt(on),
|
|
});
|
|
}
|
|
return rings;
|
|
});
|
|
},
|
|
clicked() {
|
|
const el = document.activeElement ?? document.body;
|
|
return {
|
|
element: name(el),
|
|
focusVisible: el.matches(':focus-visible'),
|
|
outline: ringOf(el).outline,
|
|
};
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Tabs from `from` through `area`, up to `limit` stops, and reports the
|
|
* rings each stop drew. `from` is entered from the keyboard (Tab, then
|
|
* Shift+Tab) so it matches `:focus-visible` like the stops after it.
|
|
*/
|
|
async function tabThrough(
|
|
page: Page,
|
|
area: Locator,
|
|
from: Locator,
|
|
limit: number
|
|
): Promise<Stop[]> {
|
|
await installProbe(page);
|
|
await from.focus();
|
|
await page.keyboard.press('Tab');
|
|
await page.keyboard.press('Shift+Tab');
|
|
await expect(from).toBeFocused();
|
|
|
|
const stops: Stop[] = [];
|
|
while (stops.length < limit) {
|
|
const inside = await area.evaluate((root) =>
|
|
root.contains(document.activeElement)
|
|
);
|
|
if (!inside) break;
|
|
const focused = await callProbe(page, 'capture');
|
|
await page.keyboard.press('Tab');
|
|
const rings = await callProbe(page, 'settle');
|
|
stops.push({ ...focused, rings });
|
|
}
|
|
return stops;
|
|
}
|
|
|
|
/** Every stop shows one uncut ring with 3:1 against a flat background. */
|
|
function expectOneVisibleRing(stops: Stop[], context: string): void {
|
|
expect(stops.length, `${context}: Tab stops`).toBeGreaterThan(1);
|
|
for (const stop of stops) {
|
|
const where = `${context}: ${stop.element} "${stop.label}"`;
|
|
expect(stop.rings, where).toHaveLength(1);
|
|
const [ring] = stop.rings;
|
|
expect(ring.clippedBy, `${where} ring clipped`).toBeNull();
|
|
// One ring colour everywhere. A select shows focus on its Material
|
|
// form-field outline, which keeps the theme's field colours.
|
|
if (ring.kind !== 'field-outline') {
|
|
expect(ring.color, `${where} ring colour`).toBe(ring.token);
|
|
}
|
|
if (ring.contrast !== null) {
|
|
expect(
|
|
ring.contrast,
|
|
`${where} ring contrast`
|
|
).toBeGreaterThanOrEqual(3);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** The focused element's outline, and whether focus counts as visible. */
|
|
function ringOfFocused(
|
|
page: Page
|
|
): Promise<{ focusVisible: boolean; outline: string }> {
|
|
return page.evaluate(() => {
|
|
const el = document.activeElement ?? document.body;
|
|
const style = getComputedStyle(el);
|
|
return {
|
|
focusVisible: el.matches(':focus-visible'),
|
|
outline: `${style.outlineStyle} ${style.outlineWidth} ${style.outlineColor}`,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** `--app-focus-ring` at the focused element, as `rgb(…)`. */
|
|
function focusRingRgb(page: Page): Promise<string> {
|
|
return page.evaluate(() => {
|
|
const probe = document.createElement('span');
|
|
probe.style.color = 'var(--app-focus-ring)';
|
|
(document.activeElement ?? document.body).appendChild(probe);
|
|
const color = getComputedStyle(probe).color;
|
|
probe.remove();
|
|
return color;
|
|
});
|
|
}
|
|
|
|
/** A mouse click focuses `target` without a ring. */
|
|
async function expectNoRingAfterClick(
|
|
page: Page,
|
|
target: Locator,
|
|
context: string
|
|
): Promise<void> {
|
|
await installProbe(page);
|
|
await target.click();
|
|
const clicked = await callProbe(page, 'clicked');
|
|
expect(clicked, `${context}: focus after a click`).toEqual({
|
|
element: clicked.element,
|
|
focusVisible: false,
|
|
outline: '',
|
|
});
|
|
}
|
|
|
|
async function openFirstSeries(page: Page): Promise<void> {
|
|
await openWorkspaceSection(page, 'Series');
|
|
await clickFirstGridListCard(page);
|
|
await expect(page.locator('[data-test-id="episodes-heading"]')).toBeVisible(
|
|
{ timeout: 20_000 }
|
|
);
|
|
}
|
|
|
|
async function openMoviesWithRatingFilter(page: Page): Promise<void> {
|
|
await openWorkspaceSection(page, 'Movies');
|
|
const view = page.locator('app-category-content-view');
|
|
await expect(view.locator('mat-card').first()).toBeVisible({
|
|
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();
|
|
await expect(view.locator('.rating-refinement-chip')).toBeVisible();
|
|
}
|
|
|
|
/** Switches the guide to its list view and opens a live channel's EPG. */
|
|
async function openLiveEpgList(page: Page, request: APIRequestContext) {
|
|
const fixture = await fetchXtreamLiveFixture(request, {
|
|
username: defaultXtreamUsername,
|
|
password: defaultXtreamPassword,
|
|
});
|
|
const channel = fixture.items[0];
|
|
if (!channel) throw new Error('Expected a live channel in the mock.');
|
|
// The mock's live streams redirect to an external demo stream; the
|
|
// guide needs none of it, so keep that request pending.
|
|
await page.route('https://test-streams.mux.dev/**', () => undefined);
|
|
await openSettings(page);
|
|
await openSettingsSection(page, 'epg');
|
|
await page.getByTestId('epg-view-mode-list').click();
|
|
await saveSettings(page);
|
|
await openWorkspaceSection(page, 'Live TV');
|
|
await clickCategoryByNameExact(page, fixture.categoryName);
|
|
await channelItemByTitle(page, getXtreamTitle(channel)).first().click();
|
|
const guide = page.locator('app-epg-list-view');
|
|
await expect(guide.locator('app-epg-list-view-row').first()).toBeVisible({
|
|
timeout: 20_000,
|
|
});
|
|
return guide;
|
|
}
|
|
|
|
/** Opens the command palette from the keyboard. */
|
|
async function openCommandPalette(page: Page): Promise<Locator> {
|
|
await page.locator('body').focus();
|
|
await page.keyboard.press(
|
|
`${process.platform === 'darwin' ? 'Meta' : 'Control'}+K`
|
|
);
|
|
const palette = page.locator(
|
|
'mat-dialog-container app-workspace-command-palette'
|
|
);
|
|
await expect(palette).toBeVisible();
|
|
return palette;
|
|
}
|
|
|
|
test.describe('Keyboard focus ring', () => {
|
|
test('@electron @theme every Tab stop shows one visible ring in both themes, a click none', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
const app = await launchElectronApp(dataDir);
|
|
const page = app.mainWindow;
|
|
const report: Record<string, Stop[]> = {};
|
|
try {
|
|
await addXtreamPortal(page);
|
|
await waitForXtreamWorkspaceReady(page);
|
|
|
|
const walk = async (
|
|
context: string,
|
|
area: Locator,
|
|
from: Locator,
|
|
limit: number
|
|
) => {
|
|
for (const theme of ['light', 'dark'] as const) {
|
|
await applyTheme(page, theme);
|
|
const stops = await tabThrough(page, area, from, limit);
|
|
report[`${context} (${theme})`] = stops;
|
|
expectOneVisibleRing(stops, `${context} in ${theme}`);
|
|
}
|
|
};
|
|
|
|
// Detail page: the hero's action row, then the season tabs.
|
|
await openFirstSeries(page);
|
|
const shell = page.locator('app-portal-detail-shell');
|
|
await walk(
|
|
'detail actions and season tabs',
|
|
shell,
|
|
shell.locator('app-detail-action-button button').first(),
|
|
14
|
|
);
|
|
expect(
|
|
report['detail actions and season tabs (dark)'].map(
|
|
(stop) => stop.element
|
|
)
|
|
).toContain('button.season-tabs__pill');
|
|
|
|
const pill = shell.locator('.season-tabs__pill').first();
|
|
await expectNoRingAfterClick(page, pill, 'season tab');
|
|
await expectNoRingAfterClick(
|
|
page,
|
|
shell.locator('.mat-button-toggle-button').last(),
|
|
'episode view toggle'
|
|
);
|
|
|
|
// A catalog grid and its refinement chips. Closing the refine
|
|
// menu with a click hands focus back to its trigger by script:
|
|
// still pointer focus, so no ring.
|
|
await openMoviesWithRatingFilter(page);
|
|
await installProbe(page);
|
|
expect(await callProbe(page, 'clicked')).toEqual({
|
|
element: expect.stringContaining('mat-mdc-button-base'),
|
|
focusVisible: false,
|
|
outline: '',
|
|
});
|
|
const view = page.locator('app-category-content-view');
|
|
await walk(
|
|
'catalog refinements and grid',
|
|
view,
|
|
view.locator('.rating-refinement-chip'),
|
|
6
|
|
);
|
|
|
|
// Sources: the type filters, then the list's row actions.
|
|
await openSources(page);
|
|
await expect(
|
|
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(),
|
|
16
|
|
);
|
|
expect(
|
|
report['sources list (dark)'].map((stop) => stop.label)
|
|
).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.
|
|
const guide = await openLiveEpgList(page, request);
|
|
await walk(
|
|
'epg list',
|
|
guide,
|
|
guide.locator('app-epg-list-view-row').first(),
|
|
4
|
|
);
|
|
expect(
|
|
report['epg list (dark)'].map((stop) => stop.element)
|
|
).toContainEqual(expect.stringMatching(/^app-epg-list-view-row\b/));
|
|
|
|
// Settings: the section navigation, then the first page's
|
|
// controls (selects, switches, the theme switcher).
|
|
await openSettings(page);
|
|
const firstSection = page
|
|
.locator('[data-test-id^="settings-section-"]')
|
|
.first();
|
|
await walk(
|
|
'settings',
|
|
page.locator('app-workspace-shell'),
|
|
firstSection,
|
|
24
|
|
);
|
|
|
|
// A search result chosen with Enter focuses its row by script;
|
|
// the keypress keeps `:focus-visible`, so the row shows the ring.
|
|
await applyTheme(page, 'light');
|
|
const search = page.locator(
|
|
'app-workspace-shell-header input[type="search"]'
|
|
);
|
|
await search.fill('subtitles');
|
|
await page
|
|
.getByTestId('settings-search-result-show-captions')
|
|
.focus();
|
|
await page.keyboard.press('Enter');
|
|
const revealed = page.locator('[data-setting-id="show-captions"]');
|
|
await expect(revealed).toBeFocused();
|
|
expect(await ringOfFocused(page)).toEqual({
|
|
focusVisible: true,
|
|
outline: `solid 2px ${await focusRingRgb(page)}`,
|
|
});
|
|
|
|
const toggle = page
|
|
.getByTestId('settings-container')
|
|
.locator('mat-slide-toggle [role="switch"]')
|
|
.first();
|
|
await expectNoRingAfterClick(page, toggle, 'settings switch');
|
|
await toggle.click();
|
|
|
|
// The command palette: its search field draws no outline, so the
|
|
// row underlines it; then the commands.
|
|
const palette = await openCommandPalette(page);
|
|
await walk(
|
|
'command palette',
|
|
palette,
|
|
palette.locator('.palette-search input'),
|
|
3
|
|
);
|
|
await page.keyboard.press('Escape');
|
|
await expect(palette).toBeHidden();
|
|
} finally {
|
|
// Every stop and the rings it drew, for a failure on CI.
|
|
await test.info().attach('focus-stops.json', {
|
|
body: JSON.stringify(report, null, 2),
|
|
contentType: 'application/json',
|
|
});
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
});
|