322 lines
9.3 KiB
TypeScript
322 lines
9.3 KiB
TypeScript
import { expect, type APIRequestContext, type Page } from '@playwright/test';
|
|
|
|
export const renderedRoutes = [
|
|
'/',
|
|
'/platform',
|
|
'/pricing',
|
|
'/contact',
|
|
'/trust',
|
|
'/legal',
|
|
'/privacy',
|
|
'/terms',
|
|
'/imprint',
|
|
'/welcome-to-docs/',
|
|
'/guides/intro/',
|
|
'/guides/getting-started/',
|
|
'/guides/first-project-checklist/',
|
|
'/platform/evidence-review/',
|
|
'/en/',
|
|
'/en/platform',
|
|
'/en/pricing',
|
|
'/en/contact',
|
|
'/en/trust',
|
|
'/en/legal',
|
|
'/en/privacy',
|
|
'/en/terms',
|
|
'/en/imprint',
|
|
'/en/welcome-to-docs/',
|
|
'/en/guides/intro/',
|
|
'/en/guides/getting-started/',
|
|
'/en/guides/first-project-checklist/',
|
|
'/en/platform/evidence-review/',
|
|
] as const;
|
|
|
|
export const docsRoutes = [
|
|
'/welcome-to-docs/',
|
|
'/guides/intro/',
|
|
'/guides/getting-started/',
|
|
'/guides/first-project-checklist/',
|
|
'/platform/evidence-review/',
|
|
'/en/welcome-to-docs/',
|
|
'/en/guides/intro/',
|
|
'/en/guides/getting-started/',
|
|
'/en/guides/first-project-checklist/',
|
|
'/en/platform/evidence-review/',
|
|
] 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' },
|
|
} 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: 'fake checkout CTA', pattern: /\b(buy now|checkout)\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 [
|
|
['backup'],
|
|
['restore'],
|
|
['drift detection', 'drift'],
|
|
['findings'],
|
|
['evidence'],
|
|
['auditability'],
|
|
['exceptions'],
|
|
['reviews', 'review'],
|
|
]) {
|
|
expect(
|
|
terms.some(term => text.includes(term)),
|
|
`missing capability term: ${terms.join(' or ')}`
|
|
).toBe(true);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|