- Raise use-swipe-gesture mutation score to 66.13% (target 65%+) - Maintain use-reduced-motion mutation score at 76.32% (target 50%+) - Fix use-reduced-motion.ts ESLint set-state-in-effect warning - Add UJ-10 deep searcher journey (category browse → article read → content discovery) - Add 40 new test files (analytics, detail, sections, ui, lib components) - Update test-strategy-plan.md to v2.0 (sync test count to 1591) - Sync README.md with final release metrics Quality gates: TS 0 errors, ESLint 0 errors, 121 suites / 1591 tests passed
136 lines
3.6 KiB
TypeScript
136 lines
3.6 KiB
TypeScript
// @ts-nocheck
|
|
import { Page } from '@playwright/test';
|
|
import { SEOResult, MetaTagResult, HeadingResult, LinkResult, ImageResult } from '../../types';
|
|
|
|
export class SEOValidator {
|
|
constructor(private page: Page) {}
|
|
|
|
async validateSEO(): Promise<SEOResult> {
|
|
const metaTags = await this.validateMetaTags();
|
|
const headings = await this.validateHeadings();
|
|
const links = await this.validateLinks();
|
|
const images = await this.validateImages();
|
|
|
|
const score = this.calculateScore(metaTags, headings, links, images);
|
|
|
|
return {
|
|
score,
|
|
metaTags,
|
|
headings,
|
|
links,
|
|
images
|
|
};
|
|
}
|
|
|
|
async validateMetaTags(): Promise<MetaTagResult> {
|
|
const title = await this.page.title();
|
|
const description = await this.page.getAttribute('meta[name="description"]', 'content');
|
|
const keywords = await this.page.getAttribute('meta[name="keywords"]', 'content');
|
|
const ogTitle = await this.page.getAttribute('meta[property="og:title"]', 'content');
|
|
const ogDescription = await this.page.getAttribute('meta[property="og:description"]', 'content');
|
|
const canonical = await this.page.getAttribute('link[rel="canonical"]', 'href');
|
|
|
|
return {
|
|
title: !!title && title.length >= 10 && title.length <= 60,
|
|
description: !!description && description.length >= 50 && description.length <= 160,
|
|
keywords: !!keywords,
|
|
ogTitle: !!ogTitle,
|
|
ogDescription: !!ogDescription,
|
|
canonical: !!canonical
|
|
};
|
|
}
|
|
|
|
async validateHeadings(): Promise<HeadingResult> {
|
|
const h1Count = await this.page.locator('h1').count();
|
|
const hasH1 = h1Count > 0;
|
|
const multipleH1 = h1Count > 1;
|
|
|
|
const headings = await this.page.evaluate(() => {
|
|
const elements = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
return Array.from(elements).map(el => el.tagName);
|
|
});
|
|
|
|
let headingStructure = true;
|
|
let previousLevel = 0;
|
|
for (const heading of headings) {
|
|
const level = parseInt(heading.charAt(1));
|
|
if (level > previousLevel + 1) {
|
|
headingStructure = false;
|
|
break;
|
|
}
|
|
previousLevel = level;
|
|
}
|
|
|
|
return {
|
|
hasH1,
|
|
headingStructure,
|
|
multipleH1
|
|
};
|
|
}
|
|
|
|
async validateLinks(): Promise<LinkResult> {
|
|
const links = await this.page.locator('a').all();
|
|
let internal = 0;
|
|
let external = 0;
|
|
const broken = 0;
|
|
|
|
for (const link of links) {
|
|
const href = await link.getAttribute('href');
|
|
if (!href) continue;
|
|
|
|
if (href.startsWith('http')) {
|
|
external++;
|
|
} else {
|
|
internal++;
|
|
}
|
|
}
|
|
|
|
return {
|
|
total: links.length,
|
|
broken,
|
|
internal,
|
|
external
|
|
};
|
|
}
|
|
|
|
async validateImages(): Promise<ImageResult> {
|
|
const images = await this.page.locator('img').all();
|
|
let withAlt = 0;
|
|
let withoutAlt = 0;
|
|
|
|
for (const image of images) {
|
|
const alt = await image.getAttribute('alt');
|
|
if (alt && alt.trim() !== '') {
|
|
withAlt++;
|
|
} else {
|
|
withoutAlt++;
|
|
}
|
|
}
|
|
|
|
return {
|
|
total: images.length,
|
|
withAlt,
|
|
withoutAlt
|
|
};
|
|
}
|
|
|
|
private calculateScore(metaTags: MetaTagResult, headings: HeadingResult, _links: LinkResult, images: ImageResult): number {
|
|
let score = 0;
|
|
let total = 0;
|
|
|
|
const metaTagValues = Object.values(metaTags);
|
|
score += metaTagValues.filter(v => v).length;
|
|
total += metaTagValues.length;
|
|
|
|
if (headings.hasH1) score++;
|
|
if (headings.headingStructure) score++;
|
|
if (!headings.multipleH1) score++;
|
|
total += 3;
|
|
|
|
if (images.withoutAlt === 0) score++;
|
|
total++;
|
|
|
|
return Math.round((score / total) * 100);
|
|
}
|
|
}
|