389 lines
18 KiB
TypeScript
389 lines
18 KiB
TypeScript
import { existsSync, readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { expect, test, type Page } from '@playwright/test';
|
|
import { createServer, type ViteDevServer } from 'vite';
|
|
|
|
const stylesheetFiles = [
|
|
'src/styles/tokens.css',
|
|
'src/styles/base.css',
|
|
'src/styles/layout.css',
|
|
'src/styles/components.css',
|
|
'src/styles/world.css',
|
|
'src/styles/print.css',
|
|
];
|
|
|
|
function informationLayerCss(): string {
|
|
return stylesheetFiles
|
|
.map((file) => {
|
|
const path = resolve(process.cwd(), file);
|
|
return existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
})
|
|
.join('\n');
|
|
}
|
|
|
|
type ServerRenderer = { render: (url: string, inputs: unknown) => Promise<string> };
|
|
type InputValidator = { validateProductionInputs: (content: unknown, manifest: unknown, site: unknown, preparedAssetRoot: URL) => unknown };
|
|
|
|
let vite: ViteDevServer;
|
|
let renderedPortfolio = '';
|
|
|
|
const syntheticProjectMedia = [
|
|
{ kind: 'image', assetId: 'synthetic-portrait-one', alt: 'Synthetic profile evidence.', caption: 'Fictional demo profile.' },
|
|
{ kind: 'image', assetId: 'synthetic-portrait-two', alt: 'Synthetic settings evidence.', caption: 'Fictional demo settings.' },
|
|
{ kind: 'image', assetId: 'synthetic-portrait-three', alt: 'Synthetic schedule evidence.', caption: 'Fictional demo schedule.' },
|
|
] as const;
|
|
|
|
const syntheticChartPair = [
|
|
{ kind: 'image', assetId: 'synthetic-chart-wide', alt: 'Synthetic wide chart evidence.', caption: 'Fictional wide chart.' },
|
|
{ kind: 'image', assetId: 'synthetic-chart-tall', alt: 'Synthetic tall chart evidence.', caption: 'Fictional tall chart.' },
|
|
] as const;
|
|
const approvedMedicationAssetIds = new Set([
|
|
'medication-mobile-logs',
|
|
'medication-mobile-data-settings',
|
|
'medication-mobile-notification-settings',
|
|
]);
|
|
|
|
function withSyntheticProjectMedia(contentInput: unknown, manifestInput: unknown): { content: unknown; manifest: unknown } {
|
|
const content = structuredClone(contentInput) as {
|
|
projects: { selected: Array<{ assetIds: string[]; media?: unknown; mediaLayout?: unknown }> };
|
|
};
|
|
const manifest = structuredClone(manifestInput) as { assets: Array<Record<string, unknown>> };
|
|
const project = content.projects.selected[0]!;
|
|
|
|
manifest.assets = manifest.assets.filter((asset) =>
|
|
typeof asset.id !== 'string' || !approvedMedicationAssetIds.has(asset.id));
|
|
project.assetIds = syntheticProjectMedia.map((media) => media.assetId);
|
|
project.media = syntheticProjectMedia;
|
|
project.mediaLayout = 'portrait-strip';
|
|
const chartPairProject = content.projects.selected[1]!;
|
|
chartPairProject.assetIds = syntheticChartPair.map((media) => media.assetId);
|
|
chartPairProject.media = syntheticChartPair;
|
|
chartPairProject.mediaLayout = 'chart-pair';
|
|
manifest.assets.push(...syntheticProjectMedia.map((media, index) => ({
|
|
id: media.assetId,
|
|
fileName: `${media.assetId}.webp`,
|
|
sha256: `${index + 1}`.repeat(64),
|
|
mediaType: 'image/webp',
|
|
width: 800,
|
|
height: 1200,
|
|
durationMs: null,
|
|
rightsHolder: 'Synthetic test fixture',
|
|
license: 'Test-only synthetic asset',
|
|
attribution: 'Synthetic test fixture',
|
|
})));
|
|
manifest.assets.push(
|
|
{
|
|
id: syntheticChartPair[0].assetId,
|
|
fileName: `${syntheticChartPair[0].assetId}.webp`,
|
|
sha256: '4'.repeat(64),
|
|
mediaType: 'image/webp',
|
|
width: 1200,
|
|
height: 600,
|
|
durationMs: null,
|
|
rightsHolder: 'Synthetic test fixture',
|
|
license: 'Test-only synthetic asset',
|
|
attribution: 'Synthetic test fixture',
|
|
},
|
|
{
|
|
id: syntheticChartPair[1].assetId,
|
|
fileName: `${syntheticChartPair[1].assetId}.webp`,
|
|
sha256: '5'.repeat(64),
|
|
mediaType: 'image/webp',
|
|
width: 600,
|
|
height: 1200,
|
|
durationMs: null,
|
|
rightsHolder: 'Synthetic test fixture',
|
|
license: 'Test-only synthetic asset',
|
|
attribution: 'Synthetic test fixture',
|
|
},
|
|
);
|
|
|
|
return { content, manifest };
|
|
}
|
|
|
|
test.beforeAll(async () => {
|
|
vite = await createServer({ server: { middlewareMode: true, hmr: false, ws: false }, appType: 'custom' });
|
|
const { render } = await vite.ssrLoadModule('/src/entry-server.ts') as ServerRenderer;
|
|
const { validateProductionInputs } = await vite.ssrLoadModule('/src/content/schemas.ts') as InputValidator;
|
|
const fixture = (file: string): unknown => JSON.parse(readFileSync(resolve(process.cwd(), file), 'utf8')) as unknown;
|
|
const syntheticFixture = withSyntheticProjectMedia(
|
|
fixture('content/public/portfolio-content.json'),
|
|
fixture('content/public/asset-manifest.json'),
|
|
);
|
|
const inputs = validateProductionInputs(
|
|
syntheticFixture.content,
|
|
syntheticFixture.manifest,
|
|
fixture('config/site.preview.json'),
|
|
new URL('file:///prepared-assets/'),
|
|
);
|
|
renderedPortfolio = await render('/', inputs);
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
await vite.close();
|
|
});
|
|
|
|
function informationLayerHarness(): string {
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>${informationLayerCss()}</style>
|
|
</head>
|
|
<body>
|
|
${renderedPortfolio}
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
async function loadInformationLayer(page: Page): Promise<void> {
|
|
await page.setContent(informationLayerHarness());
|
|
}
|
|
|
|
test('renders the validated eight-section portfolio structure before responsive checks', async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await loadInformationLayer(page);
|
|
|
|
await expect(page.locator('.portfolio-main > section')).toHaveCount(8);
|
|
expect(await page.locator('.portfolio-main > section').evaluateAll((sections) => sections.map((section) => section.id)))
|
|
.toEqual(['start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact']);
|
|
expect(await page.locator('.site-navigation a').evaluateAll((links) => links.map((link) => link.getAttribute('href'))))
|
|
.toEqual(['#start', '#profile', '#experience', '#infrastructure', '#selected-builds', '#additional-work', '#toolkit', '#contact']);
|
|
await expect(page.locator('#selected-builds article, #additional-work article')).not.toHaveCount(0);
|
|
await expect(page.locator('#toolkit [data-testid="toolkit-technology"]')).not.toHaveCount(0);
|
|
await expect(page.locator('#contact :is(a, summary):text-is("Email Me")')).toHaveCount(1);
|
|
await expect(page.locator('#contact a[target="_blank"]')).not.toHaveCount(0);
|
|
});
|
|
|
|
for (const viewport of [
|
|
{ width: 320, height: 800 },
|
|
{ width: 768, height: 1024 },
|
|
{ width: 1440, height: 900 },
|
|
{ width: 2560, height: 1440 },
|
|
]) {
|
|
test(`keeps information content within the viewport at ${viewport.width}px`, async ({ page }) => {
|
|
await page.setViewportSize(viewport);
|
|
await loadInformationLayer(page);
|
|
|
|
const geometry = await page.evaluate(() => {
|
|
const root = document.documentElement;
|
|
const boxes = [...document.querySelectorAll<HTMLElement>('.portfolio-shell :is(h1, h2, h3, p, a, article, section)')]
|
|
.map((element) => element.getBoundingClientRect())
|
|
.filter((rect) => rect.width > 0 && rect.height > 0);
|
|
return {
|
|
noOverflow: root.scrollWidth <= root.clientWidth + 1,
|
|
allBoxesFit: boxes.every((rect) => rect.left >= -1 && rect.right <= root.clientWidth + 1),
|
|
};
|
|
});
|
|
|
|
expect(geometry.noOverflow).toBe(true);
|
|
expect(geometry.allBoxesFit).toBe(true);
|
|
});
|
|
}
|
|
|
|
test('uses explicit desktop layout families while preserving the primary-project road corridor', async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await loadInformationLayer(page);
|
|
|
|
expect(await page.locator('.portfolio-main > section').evaluateAll((sections) =>
|
|
sections.map((section) => section.getAttribute('data-layout')),
|
|
)).toEqual(['hero', 'right-rail', 'left-timeline', 'full-diagram', 'project-stage', 'compact-rail', 'toolkit-field', 'destination']);
|
|
const projects = await page.locator('#selected-builds [data-project-priority="primary"]').evaluateAll((articles) =>
|
|
articles.map((article) => {
|
|
const rect = article.getBoundingClientRect();
|
|
return { left: rect.left, right: rect.right, width: rect.width };
|
|
}),
|
|
);
|
|
expect(projects).toHaveLength(2);
|
|
expect(projects[0]!.right).toBeLessThanOrEqual(1440 * 0.51);
|
|
expect(projects[1]!.left).toBeGreaterThanOrEqual(1440 * 0.53);
|
|
expect(projects.every((project) => project.width <= 1440 * 0.47)).toBe(true);
|
|
});
|
|
|
|
test('lays out synthetic portrait evidence in a desktop strip and preserves mobile reflow', async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await loadInformationLayer(page);
|
|
|
|
const gallery = page.locator('.project-media-gallery[data-media-layout="portrait-strip"]');
|
|
await expect(gallery).toHaveCount(1);
|
|
const desktopFigures = await gallery.locator('figure').evaluateAll((figures) => figures.map((figure) => {
|
|
const rect = figure.getBoundingClientRect();
|
|
return { left: rect.left, width: rect.width };
|
|
}));
|
|
expect(desktopFigures).toHaveLength(3);
|
|
expect(desktopFigures[1]!.left).toBeGreaterThan(desktopFigures[0]!.left);
|
|
expect(desktopFigures[2]!.left).toBeGreaterThan(desktopFigures[1]!.left);
|
|
expect(desktopFigures.every((figure) => figure.width < 480)).toBe(true);
|
|
|
|
await page.setViewportSize({ width: 320, height: 800 });
|
|
await loadInformationLayer(page);
|
|
await expect(gallery).toHaveCount(1);
|
|
expect(await gallery.evaluate((node) => node.scrollWidth <= node.clientWidth || getComputedStyle(node).overflowX === 'auto')).toBe(true);
|
|
expect(await gallery.locator('figure').evaluateAll((figures) => figures.map((figure) => figure.textContent)))
|
|
.toEqual(['Fictional demo profile.', 'Fictional demo settings.', 'Fictional demo schedule.']);
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
|
});
|
|
|
|
test('aligns chart-pair captions for deliberately mixed source aspect ratios', async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await loadInformationLayer(page);
|
|
|
|
const captions = page.locator('[data-project-id="footy-tracker"] .project-media-gallery[data-media-layout="chart-pair"] figcaption');
|
|
await expect(captions).toHaveCount(2);
|
|
const tops = await captions.evaluateAll((nodes) => nodes.map((node) => node.getBoundingClientRect().top));
|
|
expect(Math.abs(tops[0]! - tops[1]!)).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
test('keeps portrait evidence and its caption within an isolated print-page height', async ({ page }) => {
|
|
await page.setViewportSize({ width: 800, height: 1000 });
|
|
await page.emulateMedia({ media: 'print' });
|
|
await loadInformationLayer(page);
|
|
|
|
const portrait = page.locator('.project-media-gallery[data-media-layout="portrait-strip"] figure').first();
|
|
const printGeometry = await portrait.evaluate((figure) => {
|
|
const image = figure.querySelector('img');
|
|
const caption = figure.querySelector('figcaption');
|
|
if (image === null || caption === null) throw new Error('Missing synthetic portrait image or caption.');
|
|
const imageRect = image.getBoundingClientRect();
|
|
const captionRect = caption.getBoundingClientRect();
|
|
const figureStyle = getComputedStyle(figure);
|
|
return {
|
|
mediaAndCaptionHeight: imageRect.height + captionRect.height + Number.parseFloat(figureStyle.marginBlockEnd),
|
|
printableHeight: window.innerHeight - 3 * 16,
|
|
};
|
|
});
|
|
expect(printGeometry.mediaAndCaptionHeight).toBeLessThanOrEqual(printGeometry.printableHeight);
|
|
});
|
|
|
|
test('uses a stable tablet rhythm and a single readable mobile column with usable navigation targets', async ({ page }) => {
|
|
await page.setViewportSize({ width: 768, height: 1024 });
|
|
await loadInformationLayer(page);
|
|
const tabletPanels = await page.locator('.portfolio-main > .portfolio-section').evaluateAll((sections) =>
|
|
Object.fromEntries(sections.map((section) => [section.getAttribute('data-layout'), section.getBoundingClientRect().toJSON()])),
|
|
);
|
|
expect(tabletPanels['right-rail'].left).toBeGreaterThan(768 * 0.3);
|
|
expect(tabletPanels['left-timeline'].left).toBeLessThan(768 * 0.1);
|
|
expect(tabletPanels['full-diagram'].width).toBeGreaterThan(768 * 0.8);
|
|
expect(tabletPanels['compact-rail'].left).toBeGreaterThan(768 * 0.3);
|
|
|
|
await page.setViewportSize({ width: 320, height: 800 });
|
|
await loadInformationLayer(page);
|
|
const mobile = await page.evaluate(() => {
|
|
const root = document.documentElement;
|
|
const panels = [...document.querySelectorAll<HTMLElement>('.portfolio-main > .portfolio-section')]
|
|
.map((section) => section.getBoundingClientRect());
|
|
const navigationTargets = [...document.querySelectorAll<HTMLElement>('.site-navigation a')]
|
|
.map((link) => link.getBoundingClientRect());
|
|
return {
|
|
singleColumn: panels.every((panel) => panel.left >= 0 && panel.right <= root.clientWidth && panel.width >= root.clientWidth * 0.85),
|
|
touchTargets: navigationTargets.every((target) => target.width >= 44 && target.height >= 44),
|
|
};
|
|
});
|
|
expect(mobile.singleColumn).toBe(true);
|
|
expect(mobile.touchTargets).toBe(true);
|
|
});
|
|
|
|
test('turns the infrastructure route from a horizontal trust flow into a readable mobile stack', async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await loadInformationLayer(page);
|
|
|
|
const desktop = await page.locator('[data-testid="infrastructure-flow"] [data-infrastructure-node]').evaluateAll((nodes) =>
|
|
nodes.map((node) => {
|
|
const rect = node.getBoundingClientRect();
|
|
return { id: node.getAttribute('data-infrastructure-node'), left: rect.left, top: rect.top, bottom: rect.bottom, width: rect.width };
|
|
}),
|
|
);
|
|
expect(desktop.slice(0, 4).every((node) => Math.abs(node.top - desktop[0]!.top) <= 1)).toBe(true);
|
|
expect(desktop[4]!.top).toBeGreaterThanOrEqual(desktop[3]!.bottom);
|
|
expect(desktop.slice(0, 4).every((node, index, nodes) => index === 0 || node.left > nodes[index - 1]!.left)).toBe(true);
|
|
|
|
await page.setViewportSize({ width: 320, height: 800 });
|
|
await loadInformationLayer(page);
|
|
const mobile = await page.locator('[data-testid="infrastructure-flow"] [data-infrastructure-node]').evaluateAll((nodes) =>
|
|
nodes.map((node) => {
|
|
const rect = node.getBoundingClientRect();
|
|
return { left: rect.left, top: rect.top, bottom: rect.bottom, width: rect.width };
|
|
}),
|
|
);
|
|
expect(mobile.every((node) => Math.abs(node.left - mobile[0]!.left) <= 1)).toBe(true);
|
|
expect(mobile.every((node) => node.width >= 200)).toBe(true);
|
|
expect(mobile.every((node, index, nodes) => index === 0 || node.top > nodes[index - 1]!.bottom)).toBe(true);
|
|
});
|
|
|
|
test('keeps the compact HUD in one safe lower row without covering the road corridor', async ({ page }) => {
|
|
for (const viewport of [
|
|
{ width: 320, height: 800 },
|
|
{ width: 1440, height: 900 },
|
|
]) {
|
|
await page.setViewportSize(viewport);
|
|
await loadInformationLayer(page);
|
|
const geometry = await page.evaluate(() => {
|
|
const controls = document.querySelector<HTMLElement>('.drive-controls');
|
|
const hud = document.querySelector<HTMLElement>('.journey-hud');
|
|
if (!controls || !hud) throw new Error('Missing vehicle HUD');
|
|
const controlRect = controls.getBoundingClientRect();
|
|
const hudRect = hud.getBoundingClientRect();
|
|
return {
|
|
aligned: Math.abs(controlRect.bottom - hudRect.bottom) <= 1,
|
|
separate: controlRect.right <= hudRect.left,
|
|
controlsTargetSize: [...controls.querySelectorAll<HTMLElement>('button')]
|
|
.every((button) => button.getBoundingClientRect().height >= 44),
|
|
centerClear: controlRect.right < window.innerWidth * 0.42 && hudRect.left > window.innerWidth * 0.58,
|
|
};
|
|
});
|
|
expect(geometry.aligned).toBe(true);
|
|
expect(geometry.separate).toBe(true);
|
|
expect(geometry.controlsTargetSize).toBe(true);
|
|
if (viewport.width >= 768) expect(geometry.centerClear).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('moves drive controls into document flow in short landscape', async ({ page }) => {
|
|
await page.setViewportSize({ width: 800, height: 390 });
|
|
await loadInformationLayer(page);
|
|
const geometry = await page.evaluate(() => {
|
|
const controls = document.querySelector<HTMLElement>('.drive-controls');
|
|
const contact = document.querySelector<HTMLElement>('#contact');
|
|
if (!controls || !contact) throw new Error('Missing short-landscape controls');
|
|
const controlsRect = controls.getBoundingClientRect();
|
|
const contactRect = contact.getBoundingClientRect();
|
|
return {
|
|
position: getComputedStyle(controls).position,
|
|
afterContact: controlsRect.top >= contactRect.bottom,
|
|
};
|
|
});
|
|
expect(geometry).toEqual({ position: 'relative', afterContact: true });
|
|
});
|
|
|
|
test('keeps actual standalone project and contact destinations at least 44 by 44px on mobile', async ({ page }) => {
|
|
await page.setViewportSize({ width: 320, height: 800 });
|
|
await loadInformationLayer(page);
|
|
|
|
const targets = await page.locator('#selected-builds article > a, #additional-work article > a, #contact a').evaluateAll((links) =>
|
|
links.map((link) => {
|
|
const rect = link.getBoundingClientRect();
|
|
return { href: link.getAttribute('href'), width: rect.width, height: rect.height };
|
|
}),
|
|
);
|
|
expect(targets.length).toBeGreaterThan(0);
|
|
expect(targets.every((target) => target.width >= 44 && target.height >= 44)).toBe(true);
|
|
});
|
|
|
|
test('removes nonessential information-layer motion when reduced motion is requested', async ({ page }) => {
|
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
await loadInformationLayer(page);
|
|
const duration = await page.locator('.portfolio-section').first().evaluate((element) => getComputedStyle(element).transitionDuration);
|
|
expect(duration).toBe('0.001s');
|
|
});
|
|
|
|
test('creates a clean print portfolio with a visible label but no resume packaging', async ({ page }) => {
|
|
await page.emulateMedia({ media: 'print' });
|
|
await loadInformationLayer(page);
|
|
await expect(page.locator('.site-navigation')).toBeHidden();
|
|
await expect(page.locator('.print-portfolio-label')).toBeVisible();
|
|
await expect(page.locator('.portfolio-section').first()).toHaveCSS('break-inside', 'avoid');
|
|
const printCopy = await page.locator('body').innerText();
|
|
expect(printCopy).not.toMatch(/\b(?:Résumé|Resume)\b/u);
|
|
await expect(page.locator('a[download]')).toHaveCount(0);
|
|
});
|