Implements website feature branch `410-public-docs-ia`. Target branch: `website-dev`. Validation: - `corepack pnpm --filter @tenantatlas/website build` - Playwright smoke coverage for public routes and docs interactions - Static claim scans for `apps/website/src`, `apps/website/public`, and `apps/website/dist` Follow-up integration path after merge: `website-dev` -> `dev`. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #412
605 lines
18 KiB
TypeScript
605 lines
18 KiB
TypeScript
import { expect, type APIRequestContext, type Page } from '@playwright/test';
|
|
|
|
export const renderedRoutes = [
|
|
'/',
|
|
'/use-cases/msp',
|
|
'/use-cases/mittelstand',
|
|
'/platform',
|
|
'/platform/review-packs',
|
|
'/evaluierung',
|
|
'/pricing',
|
|
'/contact',
|
|
'/trust',
|
|
'/legal',
|
|
'/privacy',
|
|
'/terms',
|
|
'/imprint',
|
|
'/en/',
|
|
'/en/use-cases/msp',
|
|
'/en/use-cases/mittelstand',
|
|
'/en/platform',
|
|
'/en/platform/review-packs',
|
|
'/en/evaluation',
|
|
'/en/pricing',
|
|
'/en/contact',
|
|
'/en/trust',
|
|
'/en/legal',
|
|
'/en/privacy',
|
|
'/en/terms',
|
|
'/en/imprint',
|
|
'/docs/',
|
|
'/docs/getting-started/',
|
|
'/docs/microsoft-365-provider/',
|
|
'/docs/permissions-data-access/',
|
|
'/docs/data-processing-trust/',
|
|
'/docs/policy-evidence/',
|
|
'/docs/drift-detection/',
|
|
'/docs/backups-versioning-recovery/',
|
|
'/docs/findings-exceptions-accepted-risk/',
|
|
'/docs/review-packs-decisions/',
|
|
'/docs/evaluation-pilot/',
|
|
'/docs/known-limitations/',
|
|
'/docs/faq/',
|
|
'/en/docs/',
|
|
'/en/docs/getting-started/',
|
|
'/en/docs/microsoft-365-provider/',
|
|
'/en/docs/permissions-data-access/',
|
|
'/en/docs/data-processing-trust/',
|
|
'/en/docs/policy-evidence/',
|
|
'/en/docs/drift-detection/',
|
|
'/en/docs/backups-versioning-recovery/',
|
|
'/en/docs/findings-exceptions-accepted-risk/',
|
|
'/en/docs/review-packs-decisions/',
|
|
'/en/docs/evaluation-pilot/',
|
|
'/en/docs/known-limitations/',
|
|
'/en/docs/faq/',
|
|
] as const;
|
|
|
|
export const docsRoutes = [
|
|
'/docs/',
|
|
'/docs/getting-started/',
|
|
'/docs/microsoft-365-provider/',
|
|
'/docs/permissions-data-access/',
|
|
'/docs/data-processing-trust/',
|
|
'/docs/policy-evidence/',
|
|
'/docs/drift-detection/',
|
|
'/docs/backups-versioning-recovery/',
|
|
'/docs/findings-exceptions-accepted-risk/',
|
|
'/docs/review-packs-decisions/',
|
|
'/docs/evaluation-pilot/',
|
|
'/docs/known-limitations/',
|
|
'/docs/faq/',
|
|
'/en/docs/',
|
|
'/en/docs/getting-started/',
|
|
'/en/docs/microsoft-365-provider/',
|
|
'/en/docs/permissions-data-access/',
|
|
'/en/docs/data-processing-trust/',
|
|
'/en/docs/policy-evidence/',
|
|
'/en/docs/drift-detection/',
|
|
'/en/docs/backups-versioning-recovery/',
|
|
'/en/docs/findings-exceptions-accepted-risk/',
|
|
'/en/docs/review-packs-decisions/',
|
|
'/en/docs/evaluation-pilot/',
|
|
'/en/docs/known-limitations/',
|
|
'/en/docs/faq/',
|
|
] as const;
|
|
|
|
export const redirectRouteExpectations = {
|
|
'/product': { status: 301, target: '/platform' },
|
|
'/products': { status: 301, target: '/platform' },
|
|
'/services': { status: 301, target: '/platform' },
|
|
'/blog': { status: 302, target: '/platform' },
|
|
'/insights': { status: 302, target: '/platform' },
|
|
'/en/product': { status: 301, target: '/en/platform' },
|
|
'/en/products': { status: 301, target: '/en/platform' },
|
|
'/en/services': { status: 301, target: '/en/platform' },
|
|
'/en/blog': { status: 302, target: '/en/platform' },
|
|
'/en/insights': { status: 302, target: '/en/platform' },
|
|
'/welcome-to-docs/': { status: 301, target: '/docs/' },
|
|
'/guides/intro/': { status: 301, target: '/docs/getting-started/' },
|
|
'/guides/getting-started/': {
|
|
status: 301,
|
|
target: '/docs/getting-started/',
|
|
},
|
|
'/guides/first-project-checklist/': {
|
|
status: 301,
|
|
target: '/docs/evaluation-pilot/',
|
|
},
|
|
'/platform/evidence-review/': {
|
|
status: 301,
|
|
target: '/docs/policy-evidence/',
|
|
},
|
|
'/en/welcome-to-docs/': { status: 301, target: '/en/docs/' },
|
|
'/en/guides/intro/': {
|
|
status: 301,
|
|
target: '/en/docs/getting-started/',
|
|
},
|
|
'/en/guides/getting-started/': {
|
|
status: 301,
|
|
target: '/en/docs/getting-started/',
|
|
},
|
|
'/en/guides/first-project-checklist/': {
|
|
status: 301,
|
|
target: '/en/docs/evaluation-pilot/',
|
|
},
|
|
'/en/platform/evidence-review/': {
|
|
status: 301,
|
|
target: '/en/docs/policy-evidence/',
|
|
},
|
|
} as const;
|
|
|
|
export const redirectRoutes = Object.keys(redirectRouteExpectations) as Array<
|
|
keyof typeof redirectRouteExpectations
|
|
>;
|
|
|
|
export const staticRoutes = [
|
|
'/robots.txt',
|
|
'/sitemap-index.xml',
|
|
'/manifest.json',
|
|
'/favicon.ico',
|
|
] as const;
|
|
|
|
export const canonicalSiteUrl = 'https://tenantial.com';
|
|
|
|
const forbiddenPublicPatterns = [
|
|
{ label: 'ScrewFast residue', pattern: /ScrewFast/i },
|
|
{ label: 'construction residue', pattern: /\bconstruction\b/i },
|
|
{ label: 'hardware residue', pattern: /\bhardware\b/i },
|
|
{ label: 'template residue', pattern: /\btemplate\b/i },
|
|
{ label: 'open-source residue', pattern: /open-source/i },
|
|
{ label: 'TenantAtlas residue', pattern: /TenantAtlas/i },
|
|
{ label: 'TenantPilot residue', pattern: /TenantPilot/i },
|
|
{ label: 'TenantCTRL residue', pattern: /TenantCTRL/i },
|
|
{ label: 'fake social proof', pattern: /trusted by/i },
|
|
{ label: 'SOC 2 claim', pattern: /SOC\s*2/i },
|
|
{ label: 'ISO 27001 claim', pattern: /ISO\s*27001/i },
|
|
{ label: 'uptime claim', pattern: /99\.9%|guaranteed uptime/i },
|
|
{ label: 'Microsoft endorsement', pattern: /Microsoft endorsed/i },
|
|
{ label: 'Microsoft certification claim', pattern: /Microsoft certified/i },
|
|
{ label: 'recovery guarantee', pattern: /guaranteed recovery/i },
|
|
{ label: 'compliance guarantee', pattern: /guaranteed compliance/i },
|
|
{ label: 'Intune-only tool claim', pattern: /Intune Management Tool/i },
|
|
{ label: 'backup-tool claim', pattern: /Intune backup tool/i },
|
|
{ label: 'DSGVO compliance claim', pattern: /DSGVO compliant/i },
|
|
{ label: 'GDPR compliance claim', pattern: /GDPR compliant/i },
|
|
{ label: 'ISO certified claim', pattern: /ISO certified/i },
|
|
{ label: 'Google supported claim', pattern: /Google supported/i },
|
|
{ label: 'AWS supported claim', pattern: /AWS supported/i },
|
|
{ label: 'automatic restore claim', pattern: /automatic restore/i },
|
|
{ label: 'one-click restore claim', pattern: /one[- ]click restore/i },
|
|
{ label: 'immutable backup claim', pattern: /immutable backups?/i },
|
|
{
|
|
label: 'real-time drift detection claim',
|
|
pattern: /real[- ]time drift(?: detection)?/i,
|
|
},
|
|
{ label: 'court-proof evidence claim', pattern: /court[- ]proof evidence/i },
|
|
{ label: 'autonomous remediation claim', pattern: /autonomous remediation/i },
|
|
{
|
|
label: 'customer-safe productization residue',
|
|
pattern: /customer-safe consumption productization/i,
|
|
},
|
|
{ label: 'route-owned residue', pattern: /route-owned/i },
|
|
{ label: 'artifact taxonomy residue', pattern: /artifact taxonomy/i },
|
|
{ label: 'source family residue', pattern: /source family/i },
|
|
{ label: 'capability registry residue', pattern: /capability registry/i },
|
|
{ label: 'repo-real foundation residue', pattern: /repo-real foundation/i },
|
|
{
|
|
label: 'gapless evidence claim',
|
|
pattern: /lueckenlose eviden(?:ce|z)|lückenlose eviden(?:ce|z)/i,
|
|
},
|
|
{
|
|
label: 'court-proof evidence claim in German',
|
|
pattern: /gerichtsfeste Nachweise/i,
|
|
},
|
|
{ label: 'immutable evidence claim', pattern: /immutable evidence/i },
|
|
{
|
|
label: 'immutable review packs claim',
|
|
pattern: /immutable review packs/i,
|
|
},
|
|
{ label: 'complete audit trail claim', pattern: /complete audit trail/i },
|
|
{
|
|
label: 'guaranteed audit success claim',
|
|
pattern: /guarantees audit success/i,
|
|
},
|
|
{ label: 'magic compliance claim', pattern: /macht Sie compliant/i },
|
|
{ label: 'DSGVO conform claim', pattern: /DSGVO-konform/i },
|
|
{ label: 'ISO certified claim in German', pattern: /ISO-zertifiziert/i },
|
|
{ label: 'neutral SaaS residue', pattern: /neutral SaaS visual/i },
|
|
{ label: 'lorem ipsum residue', pattern: /lorem ipsum/i },
|
|
{ label: 'fake checkout CTA', pattern: /\b(buy now|checkout)\b/i },
|
|
{ label: 'fake newsletter CTA', pattern: /\b(subscribe|newsletter)\b/i },
|
|
{
|
|
label: 'fake subscription CTA',
|
|
pattern: /\b(pay now|start subscription|activate subscription)\b/i,
|
|
},
|
|
{
|
|
label: 'fake trial CTA',
|
|
pattern: /\b(start free trial|create account)\b/i,
|
|
},
|
|
{ label: 'fake app access CTA', pattern: /\b(sign in|log in|login)\b/i },
|
|
];
|
|
|
|
const metadataSelectors = [
|
|
'title',
|
|
'meta[name="description"]',
|
|
'meta[property="og:title"]',
|
|
'meta[property="og:description"]',
|
|
'meta[property="og:url"]',
|
|
'meta[property="og:image"]',
|
|
'meta[property="twitter:url"]',
|
|
'meta[name="twitter:title"]',
|
|
'meta[name="twitter:description"]',
|
|
'meta[name="twitter:image"]',
|
|
].join(',');
|
|
|
|
function normalizePath(path: string): string {
|
|
if (path === '') {
|
|
return '/';
|
|
}
|
|
|
|
return path === '/' || path.includes('.') || path.endsWith('/')
|
|
? path
|
|
: `${path}/`;
|
|
}
|
|
|
|
function normalizeRouteForSitemap(route: string): string {
|
|
return route === '/' ? '/' : normalizePath(route);
|
|
}
|
|
|
|
function routeToCanonicalPath(route: string): string {
|
|
return route === '' ? '/' : route;
|
|
}
|
|
|
|
export async function expectNoForbiddenPublicText(page: Page): Promise<void> {
|
|
const text = await page.locator('body').innerText();
|
|
|
|
for (const { label, pattern } of forbiddenPublicPatterns) {
|
|
expect(text, label).not.toMatch(pattern);
|
|
}
|
|
}
|
|
|
|
export async function expectNoForbiddenPublicClaims(page: Page): Promise<void> {
|
|
await expectNoForbiddenPublicText(page);
|
|
|
|
const metadataText = await page
|
|
.locator(metadataSelectors)
|
|
.evaluateAll(nodes =>
|
|
nodes
|
|
.map(node => node.textContent || node.getAttribute('content') || '')
|
|
.join('\n')
|
|
);
|
|
|
|
for (const { label, pattern } of forbiddenPublicPatterns) {
|
|
expect(metadataText, `${label} in metadata`).not.toMatch(pattern);
|
|
}
|
|
}
|
|
|
|
export async function expectNoHorizontalOverflow(page: Page): Promise<void> {
|
|
const metrics = await page.evaluate(() => ({
|
|
clientWidth: document.documentElement.clientWidth,
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
}));
|
|
|
|
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth + 1);
|
|
}
|
|
|
|
export async function expectCoreCapabilitiesVisible(page: Page): Promise<void> {
|
|
const text = (await page.locator('body').innerText()).toLowerCase();
|
|
|
|
for (const terms of [
|
|
['policy governance', 'policy-governance', 'governance'],
|
|
['microsoft 365'],
|
|
['evidence'],
|
|
['drift detection', 'policy-drift', 'drift'],
|
|
['reviews', 'review'],
|
|
['audit trail', 'auditability'],
|
|
['controlled recovery', 'recovery', 'restore'],
|
|
['provider', 'policy-domänen', 'policy domains', 'further policy domains'],
|
|
]) {
|
|
expect(
|
|
terms.some(term => text.includes(term)),
|
|
`missing capability term: ${terms.join(' or ')}`
|
|
).toBe(true);
|
|
}
|
|
}
|
|
|
|
const trustSurfaceExpectations = {
|
|
de: {
|
|
heading: /Prüfbare Grenzen für DACH-Evaluierungen/i,
|
|
statuses: [
|
|
'Dokumentiert',
|
|
'Auf Anfrage',
|
|
'In Vorbereitung',
|
|
'Geplant',
|
|
'Nicht beansprucht',
|
|
'Nicht anwendbar',
|
|
],
|
|
coreTerms: [
|
|
'Hosting- und Betriebsregion',
|
|
'Datenschutz- und DSGVO-Haltung',
|
|
'Auditierbarkeit und Review-Kontext',
|
|
'Retention, Export und Löschung',
|
|
'Support-Zugriff',
|
|
'Dokumenten- und Disclosure-Readiness',
|
|
'Datenkategorien und Grenzen',
|
|
'Provider-Berechtigungen und Zugriffsklassen',
|
|
'Least Privilege und RBAC',
|
|
'Secrets und Verschlüsselung',
|
|
'Sicherer Handoff für Trust-Fragen',
|
|
],
|
|
documentTerms: [
|
|
'AVV / DPA',
|
|
'TOM / technische und organisatorische Maßnahmen',
|
|
'Subprozessoren',
|
|
'Security- und Procurement-Fragen',
|
|
],
|
|
dataTerms: [
|
|
'Account- und Workspace-Metadaten',
|
|
'Managed-Environment- und Provider-Metadaten',
|
|
'Policy- und Konfigurations-Evidence',
|
|
'Findings, Exceptions und Review-Artefakte',
|
|
'Operation- und Audit-Metadaten',
|
|
'Support- und Diagnosekontext',
|
|
],
|
|
contactHref: '/contact',
|
|
},
|
|
en: {
|
|
heading: /Reviewable boundaries for DACH evaluation/i,
|
|
statuses: [
|
|
'Documented',
|
|
'On request',
|
|
'In preparation',
|
|
'Planned',
|
|
'Not claimed',
|
|
'Not applicable',
|
|
],
|
|
coreTerms: [
|
|
'Hosting and operating region',
|
|
'Privacy and GDPR posture',
|
|
'Auditability and review context',
|
|
'Retention, export, and deletion',
|
|
'Support access',
|
|
'Document and disclosure readiness',
|
|
'Data categories and boundaries',
|
|
'Provider permissions and access classes',
|
|
'Least privilege and RBAC',
|
|
'Secrets and encryption',
|
|
'Safe handoff for trust questions',
|
|
],
|
|
documentTerms: [
|
|
'DPA',
|
|
'TOM / technical and organizational measures',
|
|
'Subprocessors',
|
|
'Security and procurement questions',
|
|
],
|
|
dataTerms: [
|
|
'Account and workspace metadata',
|
|
'Managed environment and provider metadata',
|
|
'Policy and configuration evidence',
|
|
'Findings, exceptions, and review artifacts',
|
|
'Operation and audit metadata',
|
|
'Support and diagnostic context',
|
|
],
|
|
contactHref: '/en/contact',
|
|
},
|
|
} as const;
|
|
|
|
export async function expectTrustSurfaceVisible(
|
|
page: Page,
|
|
locale: keyof typeof trustSurfaceExpectations
|
|
): Promise<void> {
|
|
const expectations = trustSurfaceExpectations[locale];
|
|
const text = await page.locator('body').innerText();
|
|
|
|
await expect(
|
|
page.getByRole('heading', { name: expectations.heading })
|
|
).toBeVisible();
|
|
|
|
for (const term of [
|
|
...expectations.statuses,
|
|
...expectations.coreTerms,
|
|
...expectations.documentTerms,
|
|
...expectations.dataTerms,
|
|
]) {
|
|
expect(text, `missing trust term: ${term}`).toContain(term);
|
|
}
|
|
}
|
|
|
|
export async function expectTrustHandoffsAreReal(
|
|
page: Page,
|
|
locale: keyof typeof trustSurfaceExpectations
|
|
): Promise<void> {
|
|
const expectations = trustSurfaceExpectations[locale];
|
|
const handoffLinks = page.locator(`a[href="${expectations.contactHref}"]`);
|
|
|
|
expect(await handoffLinks.count()).toBeGreaterThan(0);
|
|
await expectNoPlaceholderLinks(page);
|
|
}
|
|
|
|
export async function expectNoFakeTrustDownloads(page: Page): Promise<void> {
|
|
const downloadLikeLinks = await page.locator('a[href]').evaluateAll(nodes =>
|
|
nodes
|
|
.map(node => ({
|
|
text: node.textContent?.trim() || '',
|
|
href: node.getAttribute('href') || '',
|
|
}))
|
|
.filter(
|
|
link =>
|
|
/\.(pdf|docx?|xlsx?|zip)([?#].*)?$/i.test(link.href) ||
|
|
/\b(download|herunterladen)\b/i.test(link.text)
|
|
)
|
|
);
|
|
|
|
expect(downloadLikeLinks).toEqual([]);
|
|
}
|
|
|
|
export async function expectNoProviderOrDataOverclaims(
|
|
page: Page
|
|
): Promise<void> {
|
|
const text = await page.locator('body').innerText();
|
|
|
|
for (const pattern of [
|
|
/Google supported/i,
|
|
/AWS supported/i,
|
|
/automatic remediation/i,
|
|
/automatic restore/i,
|
|
/real[- ]time drift(?: detection)?/i,
|
|
/immutable evidence/i,
|
|
/immutable review packs/i,
|
|
/complete audit trail/i,
|
|
/guarantees audit success/i,
|
|
/macht Sie compliant/i,
|
|
/DSGVO-konform/i,
|
|
/ISO-zertifiziert/i,
|
|
/court[- ]proof evidence/i,
|
|
/gerichtsfeste Nachweise/i,
|
|
/no customer data stored/i,
|
|
/no personal data/i,
|
|
/keine Kundendaten/i,
|
|
/keine personenbezogenen Daten/i,
|
|
]) {
|
|
expect(text).not.toMatch(pattern);
|
|
}
|
|
}
|
|
|
|
export async function expectNoPlaceholderLinks(page: Page): Promise<void> {
|
|
const placeholders = await page
|
|
.locator('a[href="#"], a[href=""], area[href="#"], area[href=""]')
|
|
.evaluateAll(nodes =>
|
|
nodes.map(node => ({
|
|
text: node.textContent?.trim() || '',
|
|
href: node.getAttribute('href'),
|
|
}))
|
|
);
|
|
|
|
expect(placeholders).toEqual([]);
|
|
}
|
|
|
|
export async function expectPublicLinksAreIntentional(
|
|
page: Page,
|
|
request: APIRequestContext,
|
|
sourceRoute: string
|
|
): Promise<void> {
|
|
const allowedRoutes = new Set<string>(
|
|
[
|
|
...renderedRoutes,
|
|
...redirectRoutes,
|
|
...staticRoutes,
|
|
'/sitemap-0.xml',
|
|
].map(route => normalizePath(route))
|
|
);
|
|
|
|
const hrefs = await page
|
|
.locator('a[href], area[href]')
|
|
.evaluateAll(nodes =>
|
|
nodes
|
|
.map(node => node.getAttribute('href')?.trim())
|
|
.filter((href): href is string => Boolean(href))
|
|
);
|
|
|
|
for (const href of hrefs) {
|
|
expect(href, `placeholder link on ${sourceRoute}`).not.toBe('#');
|
|
|
|
if (
|
|
href.startsWith('mailto:') ||
|
|
href.startsWith('tel:') ||
|
|
href.startsWith('javascript:')
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const url = new URL(href, canonicalSiteUrl);
|
|
|
|
if (url.origin !== canonicalSiteUrl) {
|
|
expect(['http:', 'https:']).toContain(url.protocol);
|
|
continue;
|
|
}
|
|
|
|
const normalizedPath = normalizePath(url.pathname);
|
|
expect(
|
|
allowedRoutes.has(normalizedPath),
|
|
`unexpected public link ${href} on ${sourceRoute}`
|
|
).toBe(true);
|
|
|
|
if (url.hash && normalizePath(sourceRoute) === normalizedPath) {
|
|
const targetId = url.hash.slice(1);
|
|
await expect(
|
|
page.locator(`#${targetId}`),
|
|
`missing anchor target ${url.hash} on ${sourceRoute}`
|
|
).toHaveCount(1);
|
|
}
|
|
|
|
if (!url.hash || normalizePath(sourceRoute) !== normalizedPath) {
|
|
const response = await request.get(url.pathname);
|
|
expect(
|
|
response.status(),
|
|
`unexpected status for linked route ${href} on ${sourceRoute}`
|
|
).toBeLessThan(400);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function expectMetadataForRoute(
|
|
page: Page,
|
|
route: string,
|
|
expected: { title: RegExp; description: RegExp }
|
|
): Promise<void> {
|
|
await expect(page).toHaveTitle(expected.title);
|
|
await expect(page.locator('meta[name="description"]')).toHaveAttribute(
|
|
'content',
|
|
expected.description
|
|
);
|
|
|
|
const canonicalUrl = `${canonicalSiteUrl}${routeToCanonicalPath(route)}`;
|
|
|
|
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
|
|
'href',
|
|
canonicalUrl
|
|
);
|
|
await expect(page.locator('meta[property="og:url"]').last()).toHaveAttribute(
|
|
'content',
|
|
canonicalUrl
|
|
);
|
|
await expect(
|
|
page
|
|
.locator('meta[property="twitter:url"], meta[name="twitter:url"]')
|
|
.last()
|
|
).toHaveAttribute('content', canonicalUrl);
|
|
await expect(
|
|
page.locator('meta[property="og:image"]').last()
|
|
).toHaveAttribute('content', /\/_astro\/social\.[^/]+\.png$/);
|
|
await expect(
|
|
page.locator('meta[name="twitter:image"]').last()
|
|
).toHaveAttribute('content', /\/_astro\/social\.[^/]+\.png$/);
|
|
}
|
|
|
|
export function extractSitemapUrls(xml: string): string[] {
|
|
return Array.from(xml.matchAll(/<loc>([^<]+)<\/loc>/g)).map(([, url]) => url);
|
|
}
|
|
|
|
export async function readSitemapUrls(page: Page): Promise<string[]> {
|
|
await page.goto('/sitemap-0.xml');
|
|
|
|
return extractSitemapUrls(await page.content());
|
|
}
|
|
|
|
export function expectSitemapIncludesRoutes(
|
|
urls: string[],
|
|
routes: readonly string[]
|
|
): void {
|
|
for (const route of routes) {
|
|
const url = `${canonicalSiteUrl}${normalizeRouteForSitemap(route)}`;
|
|
expect(urls, `sitemap missing ${url}`).toContain(url);
|
|
}
|
|
}
|
|
|
|
export function expectSitemapExcludesRoutes(
|
|
urls: string[],
|
|
routes: readonly string[]
|
|
): void {
|
|
for (const route of routes) {
|
|
const url = `${canonicalSiteUrl}${normalizeRouteForSitemap(route)}`;
|
|
expect(urls, `sitemap should exclude ${url}`).not.toContain(url);
|
|
}
|
|
}
|