141 lines
6.9 KiB
TypeScript
141 lines
6.9 KiB
TypeScript
import { readFile } from 'node:fs/promises';
|
|
import { resolve } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { loadProductionInputs } from '@/content/load-production-inputs';
|
|
import { readProductionInputsSnapshot } from '@/entry-client';
|
|
import { render } from '@/entry-server';
|
|
import {
|
|
assemblePrerenderedHtml,
|
|
parseExactConfigArgument,
|
|
type ViteManifest,
|
|
} from '../../scripts/prerender.mjs';
|
|
|
|
const repositoryRoot = process.cwd();
|
|
const locations = {
|
|
contentJson: pathToFileURL(resolve(repositoryRoot, 'content/public/portfolio-content.json')),
|
|
assetManifestJson: pathToFileURL(resolve(repositoryRoot, 'content/public/asset-manifest.json')),
|
|
preparedAssetRoot: pathToFileURL(`${resolve(repositoryRoot, 'inputs/prepared-assets')}/`),
|
|
siteConfigJson: pathToFileURL(resolve(repositoryRoot, 'config/site.preview.json')),
|
|
};
|
|
const builtTemplate = `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Portfolio</title>
|
|
<!--portfolio-head-->
|
|
<script type="module" crossorigin src="/assets/index-test.js"></script>
|
|
<link rel="stylesheet" crossorigin href="/assets/index-test.css">
|
|
</head>
|
|
<body>
|
|
<div id="app"></div>
|
|
<!--portfolio-body-->
|
|
</body>
|
|
</html>`;
|
|
const manifest: ViteManifest = {
|
|
'index.html': {
|
|
file: 'assets/index-test.js',
|
|
src: 'index.html',
|
|
isEntry: true,
|
|
css: ['assets/index-test.css'],
|
|
},
|
|
};
|
|
|
|
const renderRawArtifact = async () => {
|
|
const inputs = await loadProductionInputs(locations);
|
|
const markup = await render('/', inputs);
|
|
const html = assemblePrerenderedHtml(builtTemplate, manifest, markup, inputs);
|
|
return { html, inputs };
|
|
};
|
|
|
|
describe('raw prerendered document', () => {
|
|
it('places complete semantic content and the inert snapshot before the executable module', async () => {
|
|
const { html, inputs } = await renderRawArtifact();
|
|
const document = new DOMParser().parseFromString(html, 'text/html');
|
|
const moduleScript = document.querySelector('script[type="module"]');
|
|
const snapshot = document.querySelector('#portfolio-inputs[type="application/json"]');
|
|
|
|
expect(document.documentElement.lang).toBe('en');
|
|
expect(document.title).toBe('Luca Pîslaru — System Administrator & Full-Stack Developer');
|
|
expect(document.querySelectorAll('h1')).toHaveLength(1);
|
|
expect(document.querySelector('h1')?.textContent).toBe('Luca Pîslaru');
|
|
expect(document.querySelector('h1 + p')?.textContent).toBe('System Administrator & Full-Stack Developer');
|
|
expect([...document.querySelectorAll('main > section')].map((section) => section.id)).toEqual([
|
|
'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact',
|
|
]);
|
|
expect(document.body.textContent).toContain('Secure self-hosted platform and lifecycle');
|
|
expect(document.body.textContent).toContain('Private self-hosted medication tracker');
|
|
expect(document.body.textContent).toContain('Linux');
|
|
expect(document.querySelectorAll('a[href^="mailto:"]')).toHaveLength(0);
|
|
expect(html).not.toContain('luca.pislaru@gmail.com');
|
|
expect(document.querySelector('#start a[href="#contact"]')?.textContent).toBe('Contact Me');
|
|
expect(document.querySelector('#contact-email-details summary')?.textContent).toBe('Email Me');
|
|
expect(document.querySelector('.contact-email__address')?.textContent).toBe('luca.pislaru [at] gmail [dot] com');
|
|
expect(document.querySelector('nav a[href="#infrastructure"]')).not.toBeNull();
|
|
expect(snapshot).not.toBeNull();
|
|
expect(moduleScript).not.toBeNull();
|
|
expect(html.indexOf('id="portfolio-inputs"')).toBeLessThan(html.indexOf('<script type="module"'));
|
|
expect(html.indexOf('Luca Pîslaru')).toBeLessThan(html.indexOf('<script type="module"'));
|
|
expect(html).not.toContain('window.__PORTFOLIO_INPUTS__');
|
|
expect(html).not.toContain('<!--portfolio-');
|
|
expect(JSON.parse(snapshot!.textContent ?? '')).toEqual(inputs);
|
|
expect(readProductionInputsSnapshot(document)).toEqual(inputs);
|
|
});
|
|
|
|
it('emits one canonical, one safe ProfilePage, and the approved preview image', async () => {
|
|
const { html, inputs } = await renderRawArtifact();
|
|
const document = new DOMParser().parseFromString(html, 'text/html');
|
|
const jsonLdNodes = document.querySelectorAll('script[type="application/ld+json"]');
|
|
const previewAsset = inputs.manifest.assets.find(({ id }) => id === inputs.content.metadata.socialPreviewAssetId)!;
|
|
|
|
expect(document.querySelectorAll('link[rel="canonical"]')).toHaveLength(1);
|
|
expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe('https://portfolio.localhost/');
|
|
expect(document.querySelectorAll('meta[property="og:image"]')).toHaveLength(1);
|
|
expect(document.querySelector('meta[property="og:image"]')?.getAttribute('content'))
|
|
.toBe(`https://portfolio.localhost/assets/${previewAsset.fileName}`);
|
|
expect(jsonLdNodes).toHaveLength(1);
|
|
const jsonLd = JSON.parse(jsonLdNodes[0]!.textContent ?? '');
|
|
expect(jsonLd['@type']).toBe('ProfilePage');
|
|
expect(jsonLd.mainEntity.name).toBe('Luca Pîslaru');
|
|
expect(jsonLd.mainEntity.jobTitle).toBe('System Administrator & Full-Stack Developer');
|
|
expect((jsonLdNodes[0]!.textContent ?? '').toLowerCase()).not.toContain('</script');
|
|
});
|
|
|
|
it('adds deterministic entry preloads without duplicating the executable module', async () => {
|
|
const { html } = await renderRawArtifact();
|
|
|
|
expect(html.match(/rel="modulepreload"/gu)).toHaveLength(1);
|
|
expect(html.match(/<script type="module"/gu)).toHaveLength(1);
|
|
expect(html).toContain('<link rel="modulepreload" href="/assets/index-test.js" />');
|
|
});
|
|
});
|
|
|
|
describe('strict prerender command line', () => {
|
|
it('accepts exactly one explicit safe repository-relative config path', () => {
|
|
expect(parseExactConfigArgument(['--config', 'config/site.preview.json']))
|
|
.toBe('config/site.preview.json');
|
|
});
|
|
|
|
it.each([
|
|
{ arguments_: [] },
|
|
{ arguments_: ['--config'] },
|
|
{ arguments_: ['--config', 'config/site.preview.json', '--config', 'config/site.preview.json'] },
|
|
{ arguments_: ['--config', 'config/site.preview.json', '--unknown'] },
|
|
{ arguments_: ['--config', '../site.preview.json'] },
|
|
{ arguments_: ['--config', '/tmp/site.preview.json'] },
|
|
{ arguments_: ['--config', 'config\\site.preview.json'] },
|
|
])('rejects missing, duplicate, unknown, or unsafe arguments: $arguments_', ({ arguments_ }) => {
|
|
expect(() => parseExactConfigArgument(arguments_)).toThrow(/exactly --config|safe repository-relative/i);
|
|
});
|
|
});
|
|
|
|
describe('approved build template', () => {
|
|
it('declares English and contains no executable input assignment', async () => {
|
|
const template = await readFile(resolve(repositoryRoot, 'index.html'), 'utf8');
|
|
|
|
expect(template).toContain('<html lang="en">');
|
|
expect(template).not.toContain('window.__PORTFOLIO_INPUTS__');
|
|
});
|
|
});
|