fix: strengthen visible content accessibility proof
This commit is contained in:
@@ -50,6 +50,10 @@ select:focus-visible,
|
|||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portfolio-main:focus-visible {
|
||||||
|
box-shadow: inset 0 0 0 3px var(--focus);
|
||||||
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--amber);
|
background: var(--amber);
|
||||||
color: #14100a;
|
color: #14100a;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from '../helpers/serve-dist';
|
import { expect, expectInsetFocusIndicator, test } from '../helpers/serve-dist';
|
||||||
|
|
||||||
const navHrefs = [
|
const navHrefs = [
|
||||||
'#start',
|
'#start',
|
||||||
@@ -94,7 +94,7 @@ test('activating the skip link focuses the visible unobscured portfolio content'
|
|||||||
'tabindex',
|
'tabindex',
|
||||||
'-1',
|
'-1',
|
||||||
);
|
);
|
||||||
await assertVisibleUnobscuredFocus(page);
|
await expectInsetFocusIndicator(page, '#portfolio-content');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('explicit section activation scrolls to and focuses its heading', async ({
|
test('explicit section activation scrolls to and focuses its heading', async ({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect, test } from '../helpers/serve-dist';
|
import type { PortfolioContent } from '@/content/types';
|
||||||
|
import { expect, expectInsetFocusIndicator, test } from '../helpers/serve-dist';
|
||||||
|
|
||||||
test.use({ javaScriptEnabled: false });
|
test.use({ javaScriptEnabled: false });
|
||||||
|
|
||||||
@@ -24,15 +25,156 @@ const sectionHeadings = [
|
|||||||
'CONTACT',
|
'CONTACT',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const visibleTextLeafSelector = 'h1,h2,h3,p,dt,dd,li,figcaption,a';
|
const visibleTextLeafSelector = 'h1,h2,h3,p,dt,dd,li,figcaption,a,svg text';
|
||||||
const visibleTextSelector = [
|
const visibleTextSelector = [
|
||||||
`.site-header :is(${visibleTextLeafSelector})`,
|
'.site-header :is(h1,h2,h3,p,dt,dd,li,figcaption,a)',
|
||||||
`.portfolio-main :is(${visibleTextLeafSelector})`,
|
'.portfolio-main :is(h1,h2,h3,p,dt,dd,li,figcaption,a)',
|
||||||
|
'.portfolio-main svg text',
|
||||||
].join(',');
|
].join(',');
|
||||||
|
|
||||||
const normalizedText = (value: string): string =>
|
const normalizedText = (value: string): string =>
|
||||||
value.replace(/\s+/gu, ' ').trim();
|
value.replace(/\s+/gu, ' ').trim();
|
||||||
|
|
||||||
|
type TextMultiset = Array<[text: string, count: number]>;
|
||||||
|
|
||||||
|
const textMultiset = (values: readonly string[]): TextMultiset => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const value of values.map(normalizedText).filter(Boolean)) {
|
||||||
|
counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...counts].sort(([left], [right]) =>
|
||||||
|
left < right ? -1 : left > right ? 1 : 0,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const missingText = (
|
||||||
|
expected: TextMultiset,
|
||||||
|
actual: TextMultiset,
|
||||||
|
): TextMultiset => {
|
||||||
|
const actualCounts = new Map(actual);
|
||||||
|
return expected.flatMap(([text, count]) => {
|
||||||
|
const missing = count - (actualCounts.get(text) ?? 0);
|
||||||
|
return missing > 0 ? [[text, missing] as [string, number]] : [];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const portfolioContentFromSnapshot = async (
|
||||||
|
page: import('@playwright/test').Page,
|
||||||
|
rawHtml: string,
|
||||||
|
): Promise<PortfolioContent> =>
|
||||||
|
page.evaluate((html) => {
|
||||||
|
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
const snapshot = document.querySelector('#portfolio-inputs');
|
||||||
|
if (snapshot?.textContent === null || snapshot?.textContent === undefined) {
|
||||||
|
throw new Error('Missing #portfolio-inputs snapshot');
|
||||||
|
}
|
||||||
|
return (JSON.parse(snapshot.textContent) as { content: PortfolioContent })
|
||||||
|
.content;
|
||||||
|
}, rawHtml);
|
||||||
|
|
||||||
|
const expectedVisibleTextSegments = (
|
||||||
|
content: Readonly<PortfolioContent>,
|
||||||
|
): string[] => {
|
||||||
|
const navigationById = new Map(
|
||||||
|
content.navigation.map((item) => [item.id, item.label]),
|
||||||
|
);
|
||||||
|
const section = (
|
||||||
|
id: (typeof sectionIds)[number],
|
||||||
|
index: number,
|
||||||
|
body: readonly string[],
|
||||||
|
): string[] => {
|
||||||
|
const label = navigationById.get(id);
|
||||||
|
if (label === undefined) throw new Error(`Missing navigation label: ${id}`);
|
||||||
|
const heading = label.toUpperCase();
|
||||||
|
return [`${String(index).padStart(2, '0')} / ${heading}`, heading, ...body];
|
||||||
|
};
|
||||||
|
const projectLinkText = (
|
||||||
|
project: PortfolioContent['projects']['selected'][number],
|
||||||
|
): string[] =>
|
||||||
|
project.visibility === 'public' && project.url ? [project.title] : [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
content.identity.name,
|
||||||
|
content.identity.role,
|
||||||
|
...sectionIds.map((id) => {
|
||||||
|
const label = navigationById.get(id);
|
||||||
|
if (label === undefined)
|
||||||
|
throw new Error(`Missing navigation label: ${id}`);
|
||||||
|
return label;
|
||||||
|
}),
|
||||||
|
...section('start', 1, [content.identity.valueProposition]),
|
||||||
|
...section('profile', 2, [
|
||||||
|
content.profile.introduction,
|
||||||
|
...content.profile.screeningFacts.flatMap((fact) => [
|
||||||
|
fact.label,
|
||||||
|
fact.value,
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
...section('experience', 3, [
|
||||||
|
...content.experience.flatMap((record) => [
|
||||||
|
record.role,
|
||||||
|
`${record.organization} · ${record.dates}${record.location === undefined ? '' : ` · ${record.location}`}`,
|
||||||
|
...record.highlights,
|
||||||
|
]),
|
||||||
|
content.systemsOperations.title,
|
||||||
|
content.systemsOperations.summary,
|
||||||
|
...content.systemsOperations.evidence,
|
||||||
|
]),
|
||||||
|
...section('infrastructure', 4, [
|
||||||
|
content.infrastructure.title,
|
||||||
|
content.infrastructure.summary,
|
||||||
|
...content.infrastructure.bands.map((band) => band.title),
|
||||||
|
content.infrastructure.title,
|
||||||
|
...content.infrastructure.bands.map(
|
||||||
|
(band) => `${band.title}: ${band.summary}`,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
...section(
|
||||||
|
'selected-builds',
|
||||||
|
5,
|
||||||
|
content.projects.selected.flatMap((project) => [
|
||||||
|
project.title,
|
||||||
|
project.summary,
|
||||||
|
'Problem',
|
||||||
|
project.problem,
|
||||||
|
'Contribution',
|
||||||
|
project.contribution,
|
||||||
|
'Technical decisions',
|
||||||
|
...project.technicalDecisions,
|
||||||
|
'Outcome',
|
||||||
|
project.outcome,
|
||||||
|
...projectLinkText(project),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
...section(
|
||||||
|
'additional-work',
|
||||||
|
6,
|
||||||
|
content.projects.additional.flatMap((project) => [
|
||||||
|
project.title,
|
||||||
|
project.summary,
|
||||||
|
project.contribution,
|
||||||
|
project.outcome,
|
||||||
|
...projectLinkText(project),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
...section(
|
||||||
|
'toolkit',
|
||||||
|
7,
|
||||||
|
content.toolkit.flatMap((group) => [
|
||||||
|
group.title,
|
||||||
|
...group.technologies.map((technology) => technology.name),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
...section('contact', 8, [
|
||||||
|
content.identity.name,
|
||||||
|
content.identity.role,
|
||||||
|
content.contact.email,
|
||||||
|
content.contact.cvMessage,
|
||||||
|
...content.contact.social.map((link) => link.service),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
const rawVisibleTextSegments = async (
|
const rawVisibleTextSegments = async (
|
||||||
page: import('@playwright/test').Page,
|
page: import('@playwright/test').Page,
|
||||||
rawHtml: string,
|
rawHtml: string,
|
||||||
@@ -40,11 +182,51 @@ const rawVisibleTextSegments = async (
|
|||||||
page.evaluate(
|
page.evaluate(
|
||||||
({ html, leafSelector, rootSelector }) => {
|
({ html, leafSelector, rootSelector }) => {
|
||||||
const document = new DOMParser().parseFromString(html, 'text/html');
|
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||||
return [...document.querySelectorAll<HTMLElement>(rootSelector)]
|
const isHidden = (element: Element): boolean => {
|
||||||
|
for (
|
||||||
|
let current: Element | null = element;
|
||||||
|
current;
|
||||||
|
current = current.parentElement
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
current.matches('template,script,style,noscript') ||
|
||||||
|
current.hasAttribute('hidden') ||
|
||||||
|
current.hasAttribute('inert')
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const ariaHidden = current.getAttribute('aria-hidden');
|
||||||
|
if (
|
||||||
|
ariaHidden !== null &&
|
||||||
|
ariaHidden.trim().toLowerCase() !== 'false'
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const hiddenClass =
|
||||||
|
current.getAttribute('class')?.toLowerCase() ?? '';
|
||||||
|
if (
|
||||||
|
/(?:^|\s|-)print-only(?:\s|-|$)|print-portfolio-label|visually-hidden|sr-only|screen-reader-only/u.test(
|
||||||
|
hiddenClass,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const inlineStyle =
|
||||||
|
current.getAttribute('style')?.toLowerCase() ?? '';
|
||||||
|
if (
|
||||||
|
/(?:^|;)\s*display\s*:\s*none\b/u.test(inlineStyle) ||
|
||||||
|
/(?:^|;)\s*visibility\s*:\s*hidden\b/u.test(inlineStyle) ||
|
||||||
|
/(?:^|;)\s*opacity\s*:\s*0(?:[;\s]|$)/u.test(inlineStyle)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
return [...document.querySelectorAll<Element>(rootSelector)]
|
||||||
.filter(
|
.filter(
|
||||||
(element) =>
|
(element) =>
|
||||||
element.closest('[aria-hidden="true"]') === null &&
|
!isHidden(element) && element.querySelector(leafSelector) === null,
|
||||||
element.querySelector(leafSelector) === null,
|
|
||||||
)
|
)
|
||||||
.map((element) =>
|
.map((element) =>
|
||||||
(element.textContent ?? '').replace(/\s+/gu, ' ').trim(),
|
(element.textContent ?? '').replace(/\s+/gu, ' ').trim(),
|
||||||
@@ -65,54 +247,55 @@ const browserVisibleTextSegments = async (
|
|||||||
(elements, leafSelector) =>
|
(elements, leafSelector) =>
|
||||||
elements
|
elements
|
||||||
.filter((element) => {
|
.filter((element) => {
|
||||||
const htmlElement = element as HTMLElement;
|
for (
|
||||||
const style = getComputedStyle(htmlElement);
|
let current: Element | null = element;
|
||||||
|
current;
|
||||||
|
current = current.parentElement
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
current.matches('template,script,style,noscript') ||
|
||||||
|
current.hasAttribute('hidden') ||
|
||||||
|
current.hasAttribute('inert')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const ariaHidden = current.getAttribute('aria-hidden');
|
||||||
|
if (
|
||||||
|
ariaHidden !== null &&
|
||||||
|
ariaHidden.trim().toLowerCase() !== 'false'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const hiddenClass =
|
||||||
|
current.getAttribute('class')?.toLowerCase() ?? '';
|
||||||
|
if (
|
||||||
|
/(?:^|\s|-)print-only(?:\s|-|$)|print-portfolio-label|visually-hidden|sr-only|screen-reader-only/u.test(
|
||||||
|
hiddenClass,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const style = getComputedStyle(current);
|
||||||
|
if (
|
||||||
|
style.display === 'none' ||
|
||||||
|
style.visibility === 'hidden' ||
|
||||||
|
style.opacity === '0'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
htmlElement.closest('[aria-hidden="true"]') === null &&
|
element.querySelector(leafSelector) === null &&
|
||||||
htmlElement.querySelector(leafSelector) === null &&
|
element.getClientRects().length > 0
|
||||||
htmlElement.getClientRects().length > 0 &&
|
|
||||||
style.display !== 'none' &&
|
|
||||||
style.visibility !== 'hidden' &&
|
|
||||||
style.opacity !== '0'
|
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.map((element) =>
|
.map((element) =>
|
||||||
(element as HTMLElement).innerText.replace(/\s+/gu, ' ').trim(),
|
(element.textContent ?? '').replace(/\s+/gu, ' ').trim(),
|
||||||
)
|
)
|
||||||
.filter((text) => text.length > 0),
|
.filter((text) => text.length > 0),
|
||||||
visibleTextLeafSelector,
|
visibleTextLeafSelector,
|
||||||
);
|
);
|
||||||
|
|
||||||
const assertVisibleUnobscuredFocus = async (
|
|
||||||
page: import('@playwright/test').Page,
|
|
||||||
): Promise<void> => {
|
|
||||||
await expect
|
|
||||||
.poll(() =>
|
|
||||||
page.evaluate(() => {
|
|
||||||
const active = document.activeElement;
|
|
||||||
if (!(active instanceof HTMLElement)) return false;
|
|
||||||
const rect = active.getBoundingClientRect();
|
|
||||||
const left = Math.max(0, rect.left);
|
|
||||||
const right = Math.min(window.innerWidth, rect.right);
|
|
||||||
const top = Math.max(0, rect.top);
|
|
||||||
const bottom = Math.min(window.innerHeight, rect.bottom);
|
|
||||||
if (right <= left || bottom <= top) return false;
|
|
||||||
const topElement = document.elementFromPoint(
|
|
||||||
left + (right - left) / 2,
|
|
||||||
top + Math.min(bottom - top, window.innerHeight) / 2,
|
|
||||||
);
|
|
||||||
const style = getComputedStyle(active);
|
|
||||||
return (
|
|
||||||
topElement !== null &&
|
|
||||||
(active === topElement || active.contains(topElement)) &&
|
|
||||||
style.outlineStyle !== 'none' &&
|
|
||||||
Number.parseFloat(style.outlineWidth) >= 2
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.toBe(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
test('keeps every raw SSR reading-path segment visible without JavaScript', async ({
|
test('keeps every raw SSR reading-path segment visible without JavaScript', async ({
|
||||||
page,
|
page,
|
||||||
distUrl,
|
distUrl,
|
||||||
@@ -136,14 +319,23 @@ test('keeps every raw SSR reading-path segment visible without JavaScript', asyn
|
|||||||
const rawResponse = await request.get(`${distUrl}/`);
|
const rawResponse = await request.get(`${distUrl}/`);
|
||||||
expect(rawResponse.status()).toBe(200);
|
expect(rawResponse.status()).toBe(200);
|
||||||
const rawHtml = await rawResponse.text();
|
const rawHtml = await rawResponse.text();
|
||||||
const expectedVisibleText = await rawVisibleTextSegments(page, rawHtml);
|
const content = await portfolioContentFromSnapshot(page, rawHtml);
|
||||||
|
const expectedVisibleText = textMultiset(
|
||||||
|
expectedVisibleTextSegments(content),
|
||||||
|
);
|
||||||
|
const rawVisibleText = textMultiset(
|
||||||
|
await rawVisibleTextSegments(page, rawHtml),
|
||||||
|
);
|
||||||
|
|
||||||
await page.goto(distUrl, { waitUntil: 'load' });
|
await page.goto(distUrl, { waitUntil: 'load' });
|
||||||
const actualVisibleText = await browserVisibleTextSegments(page);
|
const actualVisibleText = textMultiset(
|
||||||
|
await browserVisibleTextSegments(page),
|
||||||
|
);
|
||||||
|
|
||||||
expect(expectedVisibleText.length).toBeGreaterThan(80);
|
expect(expectedVisibleText.length).toBeGreaterThan(80);
|
||||||
|
expect(rawVisibleText).toEqual(expectedVisibleText);
|
||||||
expect(actualVisibleText).toEqual(expectedVisibleText);
|
expect(actualVisibleText).toEqual(expectedVisibleText);
|
||||||
expect(normalizedText(actualVisibleText.join(' '))).not.toContain(
|
expect(normalizedText(JSON.stringify(actualVisibleText))).not.toContain(
|
||||||
'preview-semantic-v1',
|
'preview-semantic-v1',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -206,6 +398,61 @@ test('keeps every raw SSR reading-path segment visible without JavaScript', asyn
|
|||||||
expect(failedRequests).toEqual([]);
|
expect(failedRequests).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('content oracle detects a removed highlight and an inert reading path', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const rawResponse = await request.get(`${distUrl}/`);
|
||||||
|
expect(rawResponse.status()).toBe(200);
|
||||||
|
const rawHtml = await rawResponse.text();
|
||||||
|
const content = await portfolioContentFromSnapshot(page, rawHtml);
|
||||||
|
const expected = textMultiset(expectedVisibleTextSegments(content));
|
||||||
|
const firstHighlight = content.experience[0]?.highlights[0];
|
||||||
|
expect(firstHighlight).toBeTruthy();
|
||||||
|
|
||||||
|
const withoutHighlight = await page.evaluate(
|
||||||
|
({ html, highlight }) => {
|
||||||
|
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
const target = [...document.querySelectorAll('li')].find(
|
||||||
|
(element) =>
|
||||||
|
(element.textContent ?? '').replace(/\s+/gu, ' ').trim() ===
|
||||||
|
highlight,
|
||||||
|
);
|
||||||
|
if (target === undefined) throw new Error('Expected highlight is absent');
|
||||||
|
target.remove();
|
||||||
|
return `<!doctype html>${document.documentElement.outerHTML}`;
|
||||||
|
},
|
||||||
|
{ html: rawHtml, highlight: firstHighlight ?? '' },
|
||||||
|
);
|
||||||
|
const withoutHighlightText = textMultiset(
|
||||||
|
await rawVisibleTextSegments(page, withoutHighlight),
|
||||||
|
);
|
||||||
|
expect(withoutHighlightText).not.toEqual(expected);
|
||||||
|
const missingHighlight = missingText(expected, withoutHighlightText);
|
||||||
|
expect(missingHighlight).toEqual([[normalizedText(firstHighlight ?? ''), 1]]);
|
||||||
|
expect(missingText(withoutHighlightText, expected)).toEqual([]);
|
||||||
|
|
||||||
|
const inertMain = await page.evaluate((html) => {
|
||||||
|
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
const main = document.querySelector('main');
|
||||||
|
if (main === null) throw new Error('Expected main is absent');
|
||||||
|
main.setAttribute('inert', '');
|
||||||
|
return `<!doctype html>${document.documentElement.outerHTML}`;
|
||||||
|
}, rawHtml);
|
||||||
|
const inertMainText = textMultiset(
|
||||||
|
await rawVisibleTextSegments(page, inertMain),
|
||||||
|
);
|
||||||
|
expect(inertMainText).not.toEqual(expected);
|
||||||
|
const missingWhenInert = missingText(expected, inertMainText);
|
||||||
|
expect(missingWhenInert.length).toBeGreaterThan(80);
|
||||||
|
expect(missingWhenInert).toContainEqual([
|
||||||
|
normalizedText(content.identity.valueProposition),
|
||||||
|
1,
|
||||||
|
]);
|
||||||
|
expect(missingText(inertMainText, expected)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('activating the skip link focuses visible portfolio content without JavaScript', async ({
|
test('activating the skip link focuses visible portfolio content without JavaScript', async ({
|
||||||
page,
|
page,
|
||||||
distUrl,
|
distUrl,
|
||||||
@@ -222,7 +469,7 @@ test('activating the skip link focuses visible portfolio content without JavaScr
|
|||||||
'tabindex',
|
'tabindex',
|
||||||
'-1',
|
'-1',
|
||||||
);
|
);
|
||||||
await assertVisibleUnobscuredFocus(page);
|
await expectInsetFocusIndicator(page, '#portfolio-content');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('retains native section navigation when scripts are disabled', async ({
|
test('retains native section navigation when scripts are disabled', async ({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { readFile, realpath, stat } from 'node:fs/promises';
|
import { readFile, realpath, stat } from 'node:fs/promises';
|
||||||
import { createServer, type ServerResponse } from 'node:http';
|
import { createServer, type ServerResponse } from 'node:http';
|
||||||
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||||
import { expect, test as base } from '@playwright/test';
|
import { expect, test as base, type Page } from '@playwright/test';
|
||||||
|
|
||||||
const LOOPBACK_HOST = '127.0.0.1';
|
const LOOPBACK_HOST = '127.0.0.1';
|
||||||
|
|
||||||
@@ -82,6 +82,40 @@ export interface DistPreviewServer {
|
|||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectInsetFocusIndicator(
|
||||||
|
page: Page,
|
||||||
|
selector: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.locator(selector).evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const viewport = window.visualViewport;
|
||||||
|
const viewportLeft = viewport?.offsetLeft ?? 0;
|
||||||
|
const viewportTop = viewport?.offsetTop ?? 0;
|
||||||
|
const viewportRight =
|
||||||
|
viewportLeft + (viewport?.width ?? window.innerWidth);
|
||||||
|
const viewportBottom =
|
||||||
|
viewportTop + (viewport?.height ?? window.innerHeight);
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
focused: document.activeElement === element,
|
||||||
|
intersectsViewport:
|
||||||
|
rect.right > viewportLeft &&
|
||||||
|
rect.left < viewportRight &&
|
||||||
|
rect.bottom > viewportTop &&
|
||||||
|
rect.top < viewportBottom,
|
||||||
|
insetIndicator: /\binset\b/iu.test(style.boxShadow),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toEqual({
|
||||||
|
focused: true,
|
||||||
|
intersectsViewport: true,
|
||||||
|
insetIndicator: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function startDistServer(
|
export async function startDistServer(
|
||||||
distRoot = resolve(process.cwd(), 'dist'),
|
distRoot = resolve(process.cwd(), 'dist'),
|
||||||
): Promise<DistPreviewServer> {
|
): Promise<DistPreviewServer> {
|
||||||
|
|||||||
Reference in New Issue
Block a user