From 829fed53aab9e32437a8ffa5fd814d8c416bec7e Mon Sep 17 00:00:00 2001 From: LucaP Date: Wed, 2 Sep 2026 03:25:22 +0300 Subject: [PATCH] fix: prove resilient keyboard and no-script paths --- src/components/AppShell.vue | 2 +- tests/browser/hydration.spec.ts | 102 +++++++++++++++++++++++ tests/browser/keyboard.spec.ts | 19 +++++ tests/browser/no-javascript.spec.ts | 123 +++++++++++++++++++++++++++- 4 files changed, 244 insertions(+), 2 deletions(-) diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index ea85cf4..ddc5e24 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -38,7 +38,7 @@ const canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id -
+

{{ content.identity.valueProposition }}

diff --git a/tests/browser/hydration.spec.ts b/tests/browser/hydration.spec.ts index 2bc0615..2c976a3 100644 --- a/tests/browser/hydration.spec.ts +++ b/tests/browser/hydration.spec.ts @@ -14,6 +14,17 @@ interface DocumentSnapshot { }; } +const generatedClientEntry = (rawHtml: string): string => { + const entries = [ + ...rawHtml.matchAll( + /]*\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 => @@ -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(); +}); diff --git a/tests/browser/keyboard.spec.ts b/tests/browser/keyboard.spec.ts index c475823..a1f2317 100644 --- a/tests/browser/keyboard.spec.ts +++ b/tests/browser/keyboard.spec.ts @@ -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, diff --git a/tests/browser/no-javascript.spec.ts b/tests/browser/no-javascript.spec.ts index 792c79f..1438408 100644 --- a/tests/browser/no-javascript.spec.ts +++ b/tests/browser/no-javascript.spec.ts @@ -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 => + page.evaluate( + ({ html, leafSelector, rootSelector }) => { + const document = new DOMParser().parseFromString(html, 'text/html'); + return [...document.querySelectorAll(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 => + 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 => { + 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,