fix: prove resilient keyboard and no-script paths
This commit is contained in:
@@ -38,7 +38,7 @@ const canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id
|
||||
<SiteNavigation :items="canonicalNavigation" />
|
||||
</header>
|
||||
|
||||
<main id="portfolio-content" class="portfolio-main">
|
||||
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
|
||||
<PortfolioSection id="start" :index="1" :eyebrow="sectionLabel('start').toUpperCase()" :heading="sectionLabel('start').toUpperCase()">
|
||||
<p>{{ content.identity.valueProposition }}</p>
|
||||
</PortfolioSection>
|
||||
|
||||
@@ -14,6 +14,17 @@ interface DocumentSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
const generatedClientEntry = (rawHtml: string): string => {
|
||||
const entries = [
|
||||
...rawHtml.matchAll(
|
||||
/<script\b(?=[^>]*\btype="module")(?=[^>]*\bsrc="([^"]+)")[^>]*><\/script>/gu,
|
||||
),
|
||||
].map((match) => match[1]);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatch(/^\/assets\/[^/]+\.js$/u);
|
||||
return entries[0]!;
|
||||
};
|
||||
|
||||
const captureDocument = async (
|
||||
page: import('@playwright/test').Page,
|
||||
): Promise<DocumentSnapshot> =>
|
||||
@@ -58,6 +69,7 @@ test('hydration preserves the built subject matter and landmark structure withou
|
||||
const pageErrors: string[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
const failedRequests: string[] = [];
|
||||
const nonSuccessfulResponses: string[] = [];
|
||||
const requests: string[] = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
@@ -69,6 +81,11 @@ test('hydration preserves the built subject matter and landmark structure withou
|
||||
`${request.url()}: ${request.failure()?.errorText ?? 'failed'}`,
|
||||
),
|
||||
);
|
||||
page.on('response', (response) => {
|
||||
if (response.status() < 200 || response.status() >= 300) {
|
||||
nonSuccessfulResponses.push(`${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
const unhandled: string[] = [];
|
||||
Reflect.set(globalThis, '__portfolioUnhandledRejections', unhandled);
|
||||
@@ -120,6 +137,7 @@ test('hydration preserves the built subject matter and landmark structure withou
|
||||
expect(consoleErrors).toEqual([]);
|
||||
expect(unhandledRejections).toEqual([]);
|
||||
expect(failedRequests).toEqual([]);
|
||||
expect(nonSuccessfulResponses).toEqual([]);
|
||||
expect(requests.length).toBeGreaterThan(1);
|
||||
expect(
|
||||
requests.every((requestUrl) => {
|
||||
@@ -131,3 +149,87 @@ test('hydration preserves the built subject matter and landmark structure withou
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('an aborted generated client entry leaves the complete SSR portfolio usable', async ({
|
||||
page,
|
||||
distUrl,
|
||||
request,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name === 'mobile-chromium',
|
||||
'The exact entry-failure boundary is viewport-independent; mobile no-script and skip activation run separately.',
|
||||
);
|
||||
|
||||
const rawResponse = await request.get(`${distUrl}/`);
|
||||
expect(rawResponse.status()).toBe(200);
|
||||
const rawHtml = await rawResponse.text();
|
||||
const entryUrl = new URL(generatedClientEntry(rawHtml), distUrl).href;
|
||||
const expectedText = await page.evaluate((html) => {
|
||||
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||
return (document.querySelector('.portfolio-shell')?.textContent ?? '')
|
||||
.replace(/\s+/gu, ' ')
|
||||
.trim();
|
||||
}, rawHtml);
|
||||
|
||||
const pageErrors: string[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
const failedRequests: Array<{ url: string; error: string }> = [];
|
||||
const nonSuccessfulResponses: string[] = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text());
|
||||
});
|
||||
page.on('requestfailed', (request) =>
|
||||
failedRequests.push({
|
||||
url: request.url(),
|
||||
error: request.failure()?.errorText ?? 'failed',
|
||||
}),
|
||||
);
|
||||
page.on('response', (response) => {
|
||||
if (response.status() < 200 || response.status() >= 300) {
|
||||
nonSuccessfulResponses.push(`${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
const unhandled: string[] = [];
|
||||
Reflect.set(globalThis, '__portfolioUnhandledRejections', unhandled);
|
||||
addEventListener('unhandledrejection', (event) =>
|
||||
unhandled.push(String(event.reason)),
|
||||
);
|
||||
});
|
||||
await page.route(entryUrl, (route) => route.abort('failed'));
|
||||
|
||||
await page.goto(distUrl, { waitUntil: 'load' });
|
||||
|
||||
const appMounted = await page
|
||||
.locator('#app')
|
||||
.evaluate((app) => Reflect.has(app, '__vue_app__'));
|
||||
const unhandledRejections = await page.evaluate(
|
||||
() =>
|
||||
(Reflect.get(globalThis, '__portfolioUnhandledRejections') as
|
||||
string[] | undefined) ?? [],
|
||||
);
|
||||
expect(appMounted).toBe(false);
|
||||
expect((await captureDocument(page)).text).toBe(expectedText);
|
||||
expect(failedRequests).toEqual([{ url: entryUrl, error: 'net::ERR_FAILED' }]);
|
||||
expect(pageErrors).toEqual([]);
|
||||
expect(unhandledRejections).toEqual([]);
|
||||
expect(nonSuccessfulResponses).toEqual([]);
|
||||
expect(
|
||||
consoleErrors.every((message) =>
|
||||
/Failed to load resource.*ERR_FAILED/iu.test(message),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(consoleErrors.length).toBeLessThanOrEqual(1);
|
||||
|
||||
await expect(page.locator('h1')).toHaveText('Luca Pîslaru');
|
||||
await expect(page.locator('main > section')).toHaveCount(8);
|
||||
await expect(page.locator('nav a')).toHaveCount(8);
|
||||
await expect(
|
||||
page.locator('a[href="mailto:luca.pislaru@gmail.com"]'),
|
||||
).toBeVisible();
|
||||
await page.locator('nav a[href="#contact"]').click();
|
||||
await expect(page).toHaveURL(`${distUrl}/#contact`);
|
||||
await expect(page.locator('#contact')).toBeInViewport();
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -78,6 +78,25 @@ test('tabs from the skip link through navigation and every contact action', asyn
|
||||
}
|
||||
});
|
||||
|
||||
test('activating the skip link focuses the visible unobscured portfolio content', async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator('.skip-link')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await expect(page).toHaveURL(`${distUrl}/#portfolio-content`);
|
||||
await expect(page.locator('#portfolio-content')).toBeFocused();
|
||||
await expect(page.locator('#portfolio-content')).toHaveAttribute(
|
||||
'tabindex',
|
||||
'-1',
|
||||
);
|
||||
await assertVisibleUnobscuredFocus(page);
|
||||
});
|
||||
|
||||
test('explicit section activation scrolls to and focuses its heading', async ({
|
||||
page,
|
||||
distUrl,
|
||||
|
||||
@@ -24,9 +24,99 @@ const sectionHeadings = [
|
||||
'CONTACT',
|
||||
] as const;
|
||||
|
||||
test('keeps the complete hiring-manager path readable without JavaScript', async ({
|
||||
const visibleTextLeafSelector = 'h1,h2,h3,p,dt,dd,li,figcaption,a';
|
||||
const visibleTextSelector = [
|
||||
`.site-header :is(${visibleTextLeafSelector})`,
|
||||
`.portfolio-main :is(${visibleTextLeafSelector})`,
|
||||
].join(',');
|
||||
|
||||
const normalizedText = (value: string): string =>
|
||||
value.replace(/\s+/gu, ' ').trim();
|
||||
|
||||
const rawVisibleTextSegments = async (
|
||||
page: import('@playwright/test').Page,
|
||||
rawHtml: string,
|
||||
): Promise<string[]> =>
|
||||
page.evaluate(
|
||||
({ html, leafSelector, rootSelector }) => {
|
||||
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||
return [...document.querySelectorAll<HTMLElement>(rootSelector)]
|
||||
.filter(
|
||||
(element) =>
|
||||
element.closest('[aria-hidden="true"]') === null &&
|
||||
element.querySelector(leafSelector) === null,
|
||||
)
|
||||
.map((element) =>
|
||||
(element.textContent ?? '').replace(/\s+/gu, ' ').trim(),
|
||||
)
|
||||
.filter((text) => text.length > 0);
|
||||
},
|
||||
{
|
||||
html: rawHtml,
|
||||
leafSelector: visibleTextLeafSelector,
|
||||
rootSelector: visibleTextSelector,
|
||||
},
|
||||
);
|
||||
|
||||
const browserVisibleTextSegments = async (
|
||||
page: import('@playwright/test').Page,
|
||||
): Promise<string[]> =>
|
||||
page.locator(visibleTextSelector).evaluateAll(
|
||||
(elements, leafSelector) =>
|
||||
elements
|
||||
.filter((element) => {
|
||||
const htmlElement = element as HTMLElement;
|
||||
const style = getComputedStyle(htmlElement);
|
||||
return (
|
||||
htmlElement.closest('[aria-hidden="true"]') === null &&
|
||||
htmlElement.querySelector(leafSelector) === null &&
|
||||
htmlElement.getClientRects().length > 0 &&
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
style.opacity !== '0'
|
||||
);
|
||||
})
|
||||
.map((element) =>
|
||||
(element as HTMLElement).innerText.replace(/\s+/gu, ' ').trim(),
|
||||
)
|
||||
.filter((text) => text.length > 0),
|
||||
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 ({
|
||||
page,
|
||||
distUrl,
|
||||
request,
|
||||
}) => {
|
||||
const pageErrors: string[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
@@ -43,7 +133,19 @@ test('keeps the complete hiring-manager path readable without JavaScript', async
|
||||
}
|
||||
});
|
||||
|
||||
const rawResponse = await request.get(`${distUrl}/`);
|
||||
expect(rawResponse.status()).toBe(200);
|
||||
const rawHtml = await rawResponse.text();
|
||||
const expectedVisibleText = await rawVisibleTextSegments(page, rawHtml);
|
||||
|
||||
await page.goto(distUrl, { waitUntil: 'load' });
|
||||
const actualVisibleText = await browserVisibleTextSegments(page);
|
||||
|
||||
expect(expectedVisibleText.length).toBeGreaterThan(80);
|
||||
expect(actualVisibleText).toEqual(expectedVisibleText);
|
||||
expect(normalizedText(actualVisibleText.join(' '))).not.toContain(
|
||||
'preview-semantic-v1',
|
||||
);
|
||||
|
||||
await expect(page.locator('h1:visible')).toHaveCount(1);
|
||||
await expect(page.locator('h1')).toHaveText('Luca Pîslaru');
|
||||
@@ -104,6 +206,25 @@ test('keeps the complete hiring-manager path readable without JavaScript', async
|
||||
expect(failedRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test('activating the skip link focuses visible portfolio content without JavaScript', async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: 'load' });
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator('.skip-link')).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await expect(page).toHaveURL(`${distUrl}/#portfolio-content`);
|
||||
await expect(page.locator('#portfolio-content')).toBeFocused();
|
||||
await expect(page.locator('#portfolio-content')).toHaveAttribute(
|
||||
'tabindex',
|
||||
'-1',
|
||||
);
|
||||
await assertVisibleUnobscuredFocus(page);
|
||||
});
|
||||
|
||||
test('retains native section navigation when scripts are disabled', async ({
|
||||
page,
|
||||
distUrl,
|
||||
|
||||
Reference in New Issue
Block a user