110 lines
7.8 KiB
JavaScript
110 lines
7.8 KiB
JavaScript
import fs from 'node:fs/promises';
|
||
import path from 'node:path';
|
||
import assert from 'node:assert/strict';
|
||
import { DOMParser } from 'linkedom';
|
||
|
||
const manifest = JSON.parse(await fs.readFile('dist/seo-manifest.json', 'utf8'));
|
||
const profiles = JSON.parse(await fs.readFile('../seo/service-profiles.json', 'utf8'));
|
||
globalThis.DOMParser = DOMParser;
|
||
const { servicePriceSection, priceDirection } = await import('../src/lib/serviceNavigation.js');
|
||
assert.equal(servicePriceSection('estetic/rinoplastika', []), undefined);
|
||
assert.equal(servicePriceSection('estetic/rinoplastika', [{ slug: 'plasticheskaya_hirurgiya', items: [] }]), undefined);
|
||
assert.equal(servicePriceSection('estetic/rinoplastika', [{ children: [{ slug: 'plasticheskaya_hirurgiya', items: [{ id: 1 }] }] }]).slug, 'plasticheskaya_hirurgiya');
|
||
assert.equal(priceDirection('plasticheskaya_hirurgiya', []), undefined);
|
||
assert.equal(priceDirection('plasticheskaya_hirurgiya', [{ path: 'estetic' }]).path, 'estetic');
|
||
const { sanitizeHTML } = await import('../src/lib/sanitize.js');
|
||
const { canonicalInternalHref } = await import('../src/lib/links.js');
|
||
assert.equal(canonicalInternalHref('/include/licenses_detail.php?utm_source=test#info'), '/legaldocs/privacy/?utm_source=test#info');
|
||
assert.equal(canonicalInternalHref('/company/licenses/'), '/company/licenses/');
|
||
assert.equal(canonicalInternalHref('/privacy'), '/legaldocs/privacy/');
|
||
assert.equal(canonicalInternalHref('https://other.example/privacy'), 'https://other.example/privacy');
|
||
const sanitized = sanitizeHTML('<p onclick="bad()">Текст <strong>документа</strong></p><a href="/contacts/">Контакты</a><a href="javascript:bad()">bad link</a><a href="java script:bad()">bad newline</a><svg onload="bad()"><script>bad()</script></svg><img src="x" onerror="bad()">');
|
||
assert.ok(sanitized.includes('<strong>документа</strong>'));
|
||
assert.ok(sanitized.includes('href="/contacts/"'));
|
||
assert.ok(!/javascript:|onclick|onerror|onload|<svg|<script|<img/i.test(sanitized));
|
||
const { parseBlocks } = await import('../.ssr/entry-server.js');
|
||
const parsed = parseBlocks([{ html: '<p>Описание процедуры</p><h3>Показания</h3><ul><li>Первый пункт</li></ul><script>bad()</script>' }]);
|
||
assert.equal(parsed.length, 2);
|
||
assert.equal(parsed[0].paragraphs[0], 'Описание процедуры');
|
||
assert.equal(parsed[1].title, 'Показания');
|
||
assert.equal(parsed[1].items[0], 'Первый пункт');
|
||
assert.ok(!JSON.stringify(parsed).includes('bad()'));
|
||
let sections = 0, assets = new Set();
|
||
for (const servicePath of manifest.services) {
|
||
const html = await fs.readFile(path.join('dist/services', servicePath, 'index.html'), 'utf8');
|
||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||
for (const selector of ['title', 'meta[name="description"]', 'link[rel="canonical"]', 'h1', '#page-data']) {
|
||
assert.equal(doc.querySelectorAll(selector).length, 1, `${servicePath}: ${selector} must be unique`);
|
||
}
|
||
assert.equal(doc.querySelector('link[rel="canonical"]').getAttribute('href'), `${manifest.siteURL}/services/${servicePath}/`);
|
||
if (profiles[servicePath]) {
|
||
assert.equal(doc.querySelector('h1').textContent, profiles[servicePath].h1);
|
||
assert.equal(doc.querySelector('title').textContent, profiles[servicePath].title);
|
||
assert.ok([...doc.querySelector('title').textContent].length <= 60);
|
||
assert.ok([...doc.querySelector('meta[name="description"]').getAttribute('content')].length <= 160);
|
||
}
|
||
const data = JSON.parse(doc.querySelector('#page-data').textContent);
|
||
const service = data[`service:${servicePath}`].data;
|
||
assert.equal(service.path, servicePath);
|
||
assert.ok(!Object.keys(data).some((k) => /admin|lead|session/i.test(k)), 'Public HTML must not include private API data');
|
||
if (service.content_sections.length && !service.children?.length) {
|
||
assert.ok(doc.querySelectorAll('.service-section,.service-urgent').length >= service.content_sections.length, `${servicePath}: main text missing before JS`);
|
||
sections += service.content_sections.length;
|
||
}
|
||
if (service.blocks?.some((b) => b.html?.replace(/<[^>]*>/g, '').trim()) && !service.children?.length) {
|
||
assert.ok(service.content_sections.length > 0, `${servicePath}: nonempty source content must survive prerendering`);
|
||
}
|
||
assert.equal(doc.querySelectorAll('script[type="application/ld+json"]').length, 1, `${servicePath}: missing structured data`);
|
||
for (const el of doc.querySelectorAll('script[type="application/ld+json"]')) {
|
||
const schema = JSON.parse(el.textContent);
|
||
assert.equal(schema['@context'], 'https://schema.org');
|
||
assert.ok(schema['@graph'].some((n) => n['@type'] === 'BreadcrumbList'));
|
||
}
|
||
for (const el of doc.querySelectorAll('[src],link[href]')) {
|
||
const resource = el.getAttribute('src') || el.getAttribute('href');
|
||
if (resource.startsWith('/assets/')) assets.add(resource);
|
||
}
|
||
}
|
||
for (const resource of assets) assert.ok((await fs.stat(path.join('dist', resource))).isFile(), `Missing SSR asset: ${resource}`);
|
||
assert.ok(sections > 0, 'No source content made it into HTML');
|
||
console.log(`SEO HTML verified: ${manifest.services.length} pages, ${sections} content sections before JS, ${assets.size} local assets.`);
|
||
|
||
// Живой рендер получает данные страницы через stdin кусками по 64 КБ. Русская буква на границе
|
||
// куска не должна превращаться в «<><C2AB>»: пробуем оба сдвига, чтобы граница попала внутрь буквы.
|
||
{
|
||
const { spawnSync } = await import('node:child_process');
|
||
const template = await fs.readFile('dist/index.html', 'utf8');
|
||
for (const pad of ['', 'a']) {
|
||
const text = pad + 'Субъект персональных данных имеет право. '.repeat(6000);
|
||
const request = JSON.stringify({ url: '/legaldocs/privacy/', siteURL: manifest.siteURL, template,
|
||
common: { branches: [], settings: {} },
|
||
initial: { 'page:privacy': { data: { id: 1, slug: 'privacy', title: 'Политика', blocks: [{ type: 'html', html: `<p>${text}</p>` }], seo: {} } } } });
|
||
const run = spawnSync(process.execPath, ['scripts/render-page.mjs'], { input: request, maxBuffer: 64 << 20 });
|
||
assert.equal(run.status, 0, run.stderr?.toString());
|
||
const html = run.stdout.toString('utf8');
|
||
assert.ok(html.includes('имеет право'), 'rendered page has the text');
|
||
assert.ok(!html.includes('\uFFFD'), 'multi-byte characters survived stdin chunking');
|
||
}
|
||
console.log('Live renderer keeps UTF-8 intact across 64 KB input chunks.');
|
||
}
|
||
if (process.env.VERIFY_URL) {
|
||
const base = process.env.VERIFY_URL.replace(/\/$/, '');
|
||
for (const [url, status] of [['/services/estetic/rinoplastika/', 200], ['/services/not-a-service/', 404], ['/not-a-page/', 404], ['/robots.txt', 200], ['/sitemap.xml', 200]]) {
|
||
const response = await fetch(base + url, { redirect: 'manual' });
|
||
assert.equal(response.status, status, `${url}: wrong status`);
|
||
if (url.endsWith('rinoplastika/')) assert.ok((await response.text()).includes('Ринопластика в Махачкале'));
|
||
if (url === '/sitemap.xml') {
|
||
const body = await response.text();
|
||
const sitemap = new DOMParser().parseFromString(body, 'text/xml');
|
||
const locs = [...sitemap.querySelectorAll('loc')].map((n) => n.textContent);
|
||
assert.equal(new Set(locs).size, locs.length);
|
||
assert.ok(locs.includes(`${manifest.siteURL}/services/estetic/rinoplastika/`));
|
||
assert.ok(!locs.some((l) => l.includes('not-a-service')));
|
||
}
|
||
}
|
||
const redirect = await fetch(base + '/services/estetic/rinoplastika?utm_source=test', { redirect: 'manual' });
|
||
assert.equal(redirect.status, 301);
|
||
assert.equal(redirect.headers.get('location'), '/services/estetic/rinoplastika/?utm_source=test');
|
||
console.log('Live Go routes verified: HTML, 301, 404, robots.txt, sitemap.xml.');
|
||
}
|