Files
Portfolio/tests/browser/hydration.spec.ts

236 lines
7.8 KiB
TypeScript

import { expect, test } from '../helpers/serve-dist';
interface DocumentSnapshot {
text: string;
sectionIds: string[];
sectionHeadings: string[];
h1: string;
role: string;
landmarks: {
headers: number;
mains: number;
navigations: number;
footers: number;
};
}
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> =>
page.evaluate(() => ({
text: (document.querySelector('.portfolio-shell')?.textContent ?? '')
.replace(/\s+/gu, ' ')
.trim(),
sectionIds: [
...document.querySelectorAll<HTMLElement>('main > section'),
].map((section) => section.id),
sectionHeadings: [
...document.querySelectorAll<HTMLElement>('main > section > h2'),
].map((heading) => heading.innerText.trim()),
h1: document.querySelector('h1')?.textContent?.trim() ?? '',
role: document.querySelector('.hero-journey__role')?.textContent?.trim() ?? '',
landmarks: {
headers: document.querySelectorAll('.site-header').length,
mains: document.querySelectorAll('main').length,
navigations: document.querySelectorAll('nav').length,
footers: document.querySelectorAll('footer').length,
},
}));
test('hydration preserves the built subject matter and landmark structure without errors or external fetches', async ({
browser,
page,
distUrl,
}, testInfo) => {
test.skip(
testInfo.project.name === 'mobile-chromium',
'Hydration identity is covered once; semantic and no-script behavior run on mobile.',
);
const beforePage = await browser.newPage({
javaScriptEnabled: false,
viewport: { width: 1440, height: 900 },
});
await beforePage.goto(distUrl, { waitUntil: 'load' });
const before = await captureDocument(beforePage);
await beforePage.close();
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) => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
page.on('request', (request) => requests.push(request.url()));
page.on('requestfailed', (request) =>
failedRequests.push(
`${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);
addEventListener('unhandledrejection', (event) =>
unhandled.push(String(event.reason)),
);
});
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => {
const app = document.querySelector('#app');
return app !== null && Reflect.has(app, '__vue_app__');
});
await page.waitForFunction(() => document.readyState === 'complete');
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
const after = await captureDocument(page);
const unhandledRejections = await page.evaluate(
() =>
(Reflect.get(globalThis, '__portfolioUnhandledRejections') as
string[] | undefined) ?? [],
);
expect(after).toEqual(before);
expect(after.h1).toBe('Luca Pîslaru');
expect(after.role).toBe('System Administrator & Full-Stack Developer');
expect(after.sectionIds).toEqual([
'start',
'profile',
'experience',
'infrastructure',
'selected-builds',
'additional-work',
'toolkit',
'contact',
]);
expect(after.landmarks).toEqual({
headers: 1,
mains: 1,
navigations: 1,
footers: 1,
});
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
expect(unhandledRejections).toEqual([]);
expect(failedRequests).toEqual([]);
expect(nonSuccessfulResponses).toEqual([]);
expect(requests.length).toBeGreaterThan(1);
expect(
requests.every((requestUrl) => {
const url = new URL(requestUrl);
return (
url.origin === distUrl &&
(url.pathname === '/' || url.pathname.startsWith('/assets/'))
);
}),
).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('#contact summary'),
).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 :is(a, summary):text-is("Email Me")')).toBeVisible();
});