test: lock semantic and no-script portfolio behavior
This commit is contained in:
@@ -2,6 +2,10 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/browser',
|
testDir: './tests/browser',
|
||||||
|
workers: 4,
|
||||||
|
use: {
|
||||||
|
serviceWorkers: 'block',
|
||||||
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{ name: 'desktop-chromium', use: { ...devices['Desktop Chrome'] } },
|
{ name: 'desktop-chromium', use: { ...devices['Desktop Chrome'] } },
|
||||||
{ name: 'mobile-chromium', use: { ...devices['Pixel 5'] } },
|
{ name: 'mobile-chromium', use: { ...devices['Pixel 5'] } },
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ defineProps<{
|
|||||||
<template>
|
<template>
|
||||||
<section :id="id" class="portfolio-section" :aria-labelledby="`${id}-heading`" :data-section-index="index">
|
<section :id="id" class="portfolio-section" :aria-labelledby="`${id}-heading`" :data-section-index="index">
|
||||||
<p class="route-label">{{ String(index).padStart(2, '0') }} / {{ eyebrow }}</p>
|
<p class="route-label">{{ String(index).padStart(2, '0') }} / {{ eyebrow }}</p>
|
||||||
<h2 :id="`${id}-heading`">{{ heading }}</h2>
|
<h2 :id="`${id}-heading`" tabindex="-1">{{ heading }}</h2>
|
||||||
<slot />
|
<slot />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,13 +2,42 @@
|
|||||||
import type { SectionId } from '@/content/types';
|
import type { SectionId } from '@/content/types';
|
||||||
|
|
||||||
defineProps<{ items: ReadonlyArray<{ id: SectionId; label: string }> }>();
|
defineProps<{ items: ReadonlyArray<{ id: SectionId; label: string }> }>();
|
||||||
|
|
||||||
|
const focusActivatedSection = (event: MouseEvent): void => {
|
||||||
|
if (
|
||||||
|
event.defaultPrevented ||
|
||||||
|
event.button !== 0 ||
|
||||||
|
event.altKey ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.shiftKey ||
|
||||||
|
!(event.currentTarget instanceof HTMLAnchorElement)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sectionId: string;
|
||||||
|
try {
|
||||||
|
sectionId = decodeURIComponent(event.currentTarget.hash.slice(1));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const section = document.getElementById(sectionId);
|
||||||
|
const heading = section?.querySelector<HTMLElement>(
|
||||||
|
':scope > h2[tabindex="-1"]',
|
||||||
|
);
|
||||||
|
if (heading === undefined || heading === null) return;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => heading.focus({ preventScroll: true }));
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="site-navigation" aria-label="Portfolio sections">
|
<nav class="site-navigation" aria-label="Portfolio sections">
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="item in items" :key="item.id">
|
<li v-for="item in items" :key="item.id">
|
||||||
<a :href="`#${item.id}`">{{ item.label }}</a>
|
<a :href="`#${item.id}`" @click="focusActivatedSection">{{ item.label }}</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
133
tests/browser/hydration.spec.ts
Normal file
133
tests/browser/hydration.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
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 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('.site-header > p')?.textContent?.trim() ?? '',
|
||||||
|
landmarks: {
|
||||||
|
headers: document.querySelectorAll('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 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'}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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(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);
|
||||||
|
});
|
||||||
141
tests/browser/keyboard.spec.ts
Normal file
141
tests/browser/keyboard.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { expect, test } from '../helpers/serve-dist';
|
||||||
|
|
||||||
|
const navHrefs = [
|
||||||
|
'#start',
|
||||||
|
'#profile',
|
||||||
|
'#experience',
|
||||||
|
'#infrastructure',
|
||||||
|
'#selected-builds',
|
||||||
|
'#additional-work',
|
||||||
|
'#toolkit',
|
||||||
|
'#contact',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
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 x = left + (right - left) / 2;
|
||||||
|
const y = top + (bottom - top) / 2;
|
||||||
|
const topElement = document.elementFromPoint(x, y);
|
||||||
|
const style = getComputedStyle(active);
|
||||||
|
return (
|
||||||
|
topElement !== null &&
|
||||||
|
(active === topElement ||
|
||||||
|
active.contains(topElement) ||
|
||||||
|
topElement.contains(active)) &&
|
||||||
|
style.outlineStyle !== 'none' &&
|
||||||
|
Number.parseFloat(style.outlineWidth) >= 2
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
test('tabs from the skip link through navigation and every contact action', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}) => {
|
||||||
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
const expectedTargets = [
|
||||||
|
{ selector: '.skip-link', href: '#portfolio-content' },
|
||||||
|
...navHrefs.map((href) => ({ selector: `nav a[href="${href}"]`, href })),
|
||||||
|
{
|
||||||
|
selector: '#contact a[href^="mailto:"]',
|
||||||
|
href: 'mailto:luca.pislaru@gmail.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: '#contact a[href="https://github.com/LucaPisl"]',
|
||||||
|
href: 'https://github.com/LucaPisl',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: '#contact a[href="https://gitlab.com/luca_pisl"]',
|
||||||
|
href: 'https://gitlab.com/luca_pisl',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: '#contact a[href="https://linkedin.com/in/luca-pisl"]',
|
||||||
|
href: 'https://linkedin.com/in/luca-pisl',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const target of expectedTargets) {
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
const locator = page.locator(target.selector);
|
||||||
|
await expect(locator).toBeFocused();
|
||||||
|
await expect(locator).toHaveAttribute('href', target.href);
|
||||||
|
await assertVisibleUnobscuredFocus(page);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit section activation scrolls to and focuses its heading', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(
|
||||||
|
testInfo.project.name === 'mobile-chromium',
|
||||||
|
'The full keyboard path already covers the mobile project.',
|
||||||
|
);
|
||||||
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
for (let index = 0; index < 5; index += 1) await page.keyboard.press('Tab');
|
||||||
|
await expect(page.locator('nav a[href="#infrastructure"]')).toBeFocused();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(`${distUrl}/#infrastructure`);
|
||||||
|
await expect(page.locator('#infrastructure')).toBeInViewport();
|
||||||
|
await expect(page.locator('#infrastructure-heading')).toHaveAttribute(
|
||||||
|
'tabindex',
|
||||||
|
'-1',
|
||||||
|
);
|
||||||
|
await expect(page.locator('#infrastructure-heading')).toBeFocused();
|
||||||
|
await assertVisibleUnobscuredFocus(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual wheel and PageDown scrolling never move focus', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(
|
||||||
|
testInfo.project.name === 'mobile-chromium',
|
||||||
|
'The focus invariant is engine-level rather than viewport-specific.',
|
||||||
|
);
|
||||||
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
const startLink = page.locator('nav a[href="#start"]');
|
||||||
|
await expect(startLink).toBeFocused();
|
||||||
|
|
||||||
|
await page.mouse.wheel(0, 900);
|
||||||
|
await expect(startLink).toBeFocused();
|
||||||
|
await page.keyboard.press('PageDown');
|
||||||
|
await expect(startLink).toBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('direct fragments do not steal focus while reduced-motion navigation remains accessible', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(
|
||||||
|
testInfo.project.name === 'mobile-chromium',
|
||||||
|
'The full keyboard path already covers the mobile project.',
|
||||||
|
);
|
||||||
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
|
await page.goto(`${distUrl}/#toolkit`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page.locator('#toolkit-heading')).not.toBeFocused();
|
||||||
|
|
||||||
|
await page.locator('nav a[href="#contact"]').click();
|
||||||
|
await expect(page).toHaveURL(`${distUrl}/#contact`);
|
||||||
|
await expect(page.locator('#contact-heading')).toBeFocused();
|
||||||
|
await expect(page.locator('#contact')).toBeInViewport();
|
||||||
|
});
|
||||||
117
tests/browser/no-javascript.spec.ts
Normal file
117
tests/browser/no-javascript.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { expect, test } from '../helpers/serve-dist';
|
||||||
|
|
||||||
|
test.use({ javaScriptEnabled: false });
|
||||||
|
|
||||||
|
const sectionIds = [
|
||||||
|
'start',
|
||||||
|
'profile',
|
||||||
|
'experience',
|
||||||
|
'infrastructure',
|
||||||
|
'selected-builds',
|
||||||
|
'additional-work',
|
||||||
|
'toolkit',
|
||||||
|
'contact',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const sectionHeadings = [
|
||||||
|
'START',
|
||||||
|
'PROFILE',
|
||||||
|
'EXPERIENCE',
|
||||||
|
'INFRASTRUCTURE',
|
||||||
|
'SELECTED BUILDS',
|
||||||
|
'ADDITIONAL WORK',
|
||||||
|
'TOOLKIT',
|
||||||
|
'CONTACT',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test('keeps the complete hiring-manager path readable without JavaScript', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}) => {
|
||||||
|
const pageErrors: string[] = [];
|
||||||
|
const consoleErrors: string[] = [];
|
||||||
|
const failedRequests: 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) => {
|
||||||
|
if (request.resourceType() !== 'script') {
|
||||||
|
failedRequests.push(
|
||||||
|
`${request.url()}: ${request.failure()?.errorText ?? 'failed'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(distUrl, { waitUntil: 'load' });
|
||||||
|
|
||||||
|
await expect(page.locator('h1:visible')).toHaveCount(1);
|
||||||
|
await expect(page.locator('h1')).toHaveText('Luca Pîslaru');
|
||||||
|
await expect(page.locator('.site-header > p').first()).toHaveText(
|
||||||
|
'System Administrator & Full-Stack Developer',
|
||||||
|
);
|
||||||
|
await expect(page.locator('main')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator('nav[aria-label="Portfolio sections"]'),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.locator('footer')).toBeVisible();
|
||||||
|
expect(
|
||||||
|
await page
|
||||||
|
.locator('main > section')
|
||||||
|
.evaluateAll((sections) => sections.map((section) => section.id)),
|
||||||
|
).toEqual(sectionIds);
|
||||||
|
expect(await page.locator('main > section > h2').allTextContents()).toEqual(
|
||||||
|
sectionHeadings,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const evidence of [
|
||||||
|
'Professional systems operations',
|
||||||
|
'Secure self-hosted platform and lifecycle',
|
||||||
|
'Private self-hosted medication tracker',
|
||||||
|
'Footy Tracker',
|
||||||
|
'Linux',
|
||||||
|
'Docker',
|
||||||
|
'Python',
|
||||||
|
'Vue.js',
|
||||||
|
]) {
|
||||||
|
await expect(
|
||||||
|
page.getByText(evidence, { exact: true }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = page.locator('a[href="mailto:luca.pislaru@gmail.com"]');
|
||||||
|
await expect(email).toBeVisible();
|
||||||
|
await expect(email).toHaveText('luca.pislaru@gmail.com');
|
||||||
|
await expect(
|
||||||
|
page.getByText(
|
||||||
|
'For a current CV or role-specific information, contact me directly.',
|
||||||
|
{ exact: true },
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
for (const title of [
|
||||||
|
'Private self-hosted medication tracker',
|
||||||
|
'Private healthcare platform',
|
||||||
|
'Private game prototype',
|
||||||
|
]) {
|
||||||
|
await expect(
|
||||||
|
page.locator('article').filter({ hasText: title }).locator('a'),
|
||||||
|
).toHaveCount(0);
|
||||||
|
}
|
||||||
|
await expect(page.locator('a[download]')).toHaveCount(0);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
expect(consoleErrors).toEqual([]);
|
||||||
|
expect(failedRequests).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retains native section navigation when scripts are disabled', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}) => {
|
||||||
|
await page.goto(distUrl, { waitUntil: 'load' });
|
||||||
|
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();
|
||||||
|
});
|
||||||
179
tests/browser/semantic.spec.ts
Normal file
179
tests/browser/semantic.spec.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { request as httpRequest } from 'node:http';
|
||||||
|
import { symlink, unlink } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { expect, test } from '../helpers/serve-dist';
|
||||||
|
|
||||||
|
const sectionIds = [
|
||||||
|
'start',
|
||||||
|
'profile',
|
||||||
|
'experience',
|
||||||
|
'infrastructure',
|
||||||
|
'selected-builds',
|
||||||
|
'additional-work',
|
||||||
|
'toolkit',
|
||||||
|
'contact',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const sectionHeadings = [
|
||||||
|
'START',
|
||||||
|
'PROFILE',
|
||||||
|
'EXPERIENCE',
|
||||||
|
'INFRASTRUCTURE',
|
||||||
|
'SELECTED BUILDS',
|
||||||
|
'ADDITIONAL WORK',
|
||||||
|
'TOOLKIT',
|
||||||
|
'CONTACT',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const rawStatus = async (
|
||||||
|
origin: string,
|
||||||
|
path: string,
|
||||||
|
method = 'GET',
|
||||||
|
): Promise<number | undefined> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(origin);
|
||||||
|
const request = httpRequest(
|
||||||
|
{
|
||||||
|
host: url.hostname,
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
port: url.port,
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
response.resume();
|
||||||
|
response.once('end', () => resolve(response.statusCode));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
request.once('error', reject);
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serves only the owned generated preview surface', async ({
|
||||||
|
distUrl,
|
||||||
|
request,
|
||||||
|
}, testInfo) => {
|
||||||
|
const document = await request.get(`${distUrl}/`);
|
||||||
|
expect(document.status()).toBe(200);
|
||||||
|
expect(document.headers()['content-type']).toBe('text/html; charset=utf-8');
|
||||||
|
|
||||||
|
const unsafeMethod = await request.post(`${distUrl}/index.html`);
|
||||||
|
expect(unsafeMethod.status()).toBe(405);
|
||||||
|
expect(unsafeMethod.headers().allow).toBe('GET, HEAD');
|
||||||
|
expect(await rawStatus(distUrl, '/%2e%2e/package.json')).toBe(403);
|
||||||
|
expect(
|
||||||
|
(await request.get(`${distUrl}/not-a-preview-resource`)).status(),
|
||||||
|
).toBe(404);
|
||||||
|
|
||||||
|
const linkName = `.task-7-escape-${process.pid}-${testInfo.workerIndex}`;
|
||||||
|
const linkPath = resolve(process.cwd(), 'dist', linkName);
|
||||||
|
await symlink(resolve(process.cwd(), 'package.json'), linkPath);
|
||||||
|
try {
|
||||||
|
expect((await request.get(`${distUrl}/${linkName}`)).status()).toBe(403);
|
||||||
|
} finally {
|
||||||
|
await unlink(linkPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes the complete semantic portfolio from the generated document', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}) => {
|
||||||
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
await expect(page.locator('h1:visible')).toHaveCount(1);
|
||||||
|
await expect(page.locator('h1')).toHaveText('Luca Pîslaru');
|
||||||
|
await expect(page.locator('.site-header > p').first()).toHaveText(
|
||||||
|
'System Administrator & Full-Stack Developer',
|
||||||
|
);
|
||||||
|
await expect(page.locator('main')).toHaveCount(1);
|
||||||
|
await expect(
|
||||||
|
page.locator('nav[aria-label="Portfolio sections"]'),
|
||||||
|
).toHaveCount(1);
|
||||||
|
await expect(page.locator('footer')).toHaveCount(1);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await page
|
||||||
|
.locator('main > section')
|
||||||
|
.evaluateAll((sections) => sections.map((section) => section.id)),
|
||||||
|
).toEqual(sectionIds);
|
||||||
|
expect(await page.locator('main > section > h2').allTextContents()).toEqual(
|
||||||
|
sectionHeadings,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await page.locator('h1, h2, h3').evaluateAll((headings) => {
|
||||||
|
const levels = headings.map((heading) =>
|
||||||
|
Number(heading.tagName.slice(1)),
|
||||||
|
);
|
||||||
|
return levels.every(
|
||||||
|
(level, index) => index === 0 || level <= levels[index - 1]! + 1,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps canonical native anchors and safe external destinations', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}) => {
|
||||||
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
const navigation = page.locator('nav[aria-label="Portfolio sections"] a');
|
||||||
|
expect(
|
||||||
|
await navigation.evaluateAll((links) =>
|
||||||
|
links.map((link) => link.getAttribute('href')),
|
||||||
|
),
|
||||||
|
).toEqual(sectionIds.map((id) => `#${id}`));
|
||||||
|
for (const id of sectionIds) {
|
||||||
|
await expect(page.locator(`#${id}`)).toHaveCount(1);
|
||||||
|
await expect(page.locator(`#${id}-heading`)).toHaveCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = page.locator('a[href^="mailto:"]');
|
||||||
|
await expect(email).toHaveCount(1);
|
||||||
|
await expect(email).toHaveText('luca.pislaru@gmail.com');
|
||||||
|
|
||||||
|
const externalLinks = page.locator('#contact a[target="_blank"]');
|
||||||
|
await expect(externalLinks).toHaveCount(3);
|
||||||
|
expect(
|
||||||
|
await externalLinks.evaluateAll((links) =>
|
||||||
|
links.every((link) => {
|
||||||
|
const rel = new Set((link.getAttribute('rel') ?? '').split(/\s+/u));
|
||||||
|
return (
|
||||||
|
link instanceof HTMLAnchorElement &&
|
||||||
|
link.protocol === 'https:' &&
|
||||||
|
rel.has('noopener') &&
|
||||||
|
rel.has('noreferrer')
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never links private projects or packages a résumé download', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}) => {
|
||||||
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
for (const title of [
|
||||||
|
'Private self-hosted medication tracker',
|
||||||
|
'Private healthcare platform',
|
||||||
|
'Private game prototype',
|
||||||
|
]) {
|
||||||
|
await expect(
|
||||||
|
page.locator('article').filter({ hasText: title }).locator('a'),
|
||||||
|
).toHaveCount(0);
|
||||||
|
}
|
||||||
|
await expect(page.locator('a[download]')).toHaveCount(0);
|
||||||
|
expect(
|
||||||
|
await page
|
||||||
|
.locator('a')
|
||||||
|
.evaluateAll((links) =>
|
||||||
|
links.some(
|
||||||
|
(link) =>
|
||||||
|
/(?:résumé|resume|\bcv\b)/iu.test(link.textContent ?? '') ||
|
||||||
|
/\.(?:pdf|docx?)(?:$|[?#])/iu.test(link.getAttribute('href') ?? ''),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
206
tests/helpers/serve-dist.ts
Normal file
206
tests/helpers/serve-dist.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { readFile, realpath, stat } from 'node:fs/promises';
|
||||||
|
import { createServer, type ServerResponse } from 'node:http';
|
||||||
|
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||||
|
import { expect, test as base } from '@playwright/test';
|
||||||
|
|
||||||
|
const LOOPBACK_HOST = '127.0.0.1';
|
||||||
|
|
||||||
|
const contentTypes: Readonly<Record<string, string>> = {
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
'.br': 'application/octet-stream',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.map': 'application/json; charset=utf-8',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.svg': 'image/svg+xml; charset=utf-8',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.woff': 'font/woff',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.xml': 'application/xml; charset=utf-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendText = (
|
||||||
|
response: ServerResponse,
|
||||||
|
status: number,
|
||||||
|
body: string,
|
||||||
|
): void => {
|
||||||
|
response.writeHead(status, {
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
});
|
||||||
|
response.end(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isInside = (root: string, candidate: string): boolean => {
|
||||||
|
const pathFromRoot = relative(root, candidate);
|
||||||
|
return (
|
||||||
|
pathFromRoot === '' ||
|
||||||
|
(!isAbsolute(pathFromRoot) &&
|
||||||
|
pathFromRoot !== '..' &&
|
||||||
|
!pathFromRoot.startsWith(`..${sep}`))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const decodedRequestPath = (requestTarget: string): string | null => {
|
||||||
|
const rawPath = requestTarget.split(/[?#]/u, 1)[0] ?? '/';
|
||||||
|
let decoded: string;
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(rawPath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!decoded.startsWith('/') ||
|
||||||
|
decoded.includes('\0') ||
|
||||||
|
decoded.includes('\\')
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = decoded.split('/');
|
||||||
|
if (segments.some((segment) => segment === '.' || segment === '..')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded === '/' || decoded.endsWith('/')
|
||||||
|
? `${decoded}index.html`
|
||||||
|
: decoded;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DistPreviewServer {
|
||||||
|
readonly origin: string;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startDistServer(
|
||||||
|
distRoot = resolve(process.cwd(), 'dist'),
|
||||||
|
): Promise<DistPreviewServer> {
|
||||||
|
const root = await realpath(distRoot);
|
||||||
|
if (!(await stat(root)).isDirectory()) {
|
||||||
|
throw new Error(`The preview output is not a directory: ${distRoot}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
void (async () => {
|
||||||
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||||
|
response.setHeader('Allow', 'GET, HEAD');
|
||||||
|
sendText(response, 405, 'Method not allowed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestPath = decodedRequestPath(request.url ?? '/');
|
||||||
|
if (requestPath === null) {
|
||||||
|
sendText(response, 403, 'Forbidden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = resolve(root, `.${requestPath}`);
|
||||||
|
if (!isInside(root, candidate)) {
|
||||||
|
sendText(response, 403, 'Forbidden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filePath: string;
|
||||||
|
try {
|
||||||
|
filePath = await realpath(candidate);
|
||||||
|
} catch {
|
||||||
|
sendText(response, 404, 'Not found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isInside(root, filePath)) {
|
||||||
|
sendText(response, 403, 'Forbidden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = await stat(filePath);
|
||||||
|
if (!file.isFile()) {
|
||||||
|
sendText(response, 404, 'Not found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readFile(filePath);
|
||||||
|
response.writeHead(200, {
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Content-Length': body.byteLength,
|
||||||
|
'Content-Type':
|
||||||
|
contentTypes[extname(filePath).toLowerCase()] ??
|
||||||
|
'application/octet-stream',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
});
|
||||||
|
response.end(request.method === 'HEAD' ? undefined : body);
|
||||||
|
})().catch(() => {
|
||||||
|
if (!response.headersSent) {
|
||||||
|
sendText(response, 500, 'Internal server error.');
|
||||||
|
} else {
|
||||||
|
response.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolveListening, reject) => {
|
||||||
|
const onError = (error: Error): void => reject(error);
|
||||||
|
server.once('error', onError);
|
||||||
|
server.listen({ host: LOOPBACK_HOST, port: 0, exclusive: true }, () => {
|
||||||
|
server.off('error', onError);
|
||||||
|
resolveListening();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
if (
|
||||||
|
address === null ||
|
||||||
|
typeof address === 'string' ||
|
||||||
|
address.address !== LOOPBACK_HOST
|
||||||
|
) {
|
||||||
|
server.closeAllConnections();
|
||||||
|
await new Promise<void>((resolveClosed) =>
|
||||||
|
server.close(() => resolveClosed()),
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
'The preview server did not bind the owned loopback listener.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
return {
|
||||||
|
origin: `http://${LOOPBACK_HOST}:${address.port}`,
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
await new Promise<void>((resolveClosed, reject) => {
|
||||||
|
server.close((error) =>
|
||||||
|
error === undefined ? resolveClosed() : reject(error),
|
||||||
|
);
|
||||||
|
server.closeIdleConnections();
|
||||||
|
server.closeAllConnections();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const test = base.extend<{}, { distUrl: string }>({
|
||||||
|
distUrl: [
|
||||||
|
async ({}, use) => {
|
||||||
|
const preview = await startDistServer();
|
||||||
|
try {
|
||||||
|
await use(preview.origin);
|
||||||
|
} finally {
|
||||||
|
await preview.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ scope: 'worker' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect };
|
||||||
Reference in New Issue
Block a user