SEO + AEO

This commit is contained in:
2025-09-04 10:35:41 +02:00
parent 871836f497
commit bccaefedb3
29 changed files with 5951 additions and 116 deletions

161
tests/e2e/canonical.spec.ts Normal file
View File

@@ -0,0 +1,161 @@
import { test, expect } from '@playwright/test'
test.describe('Canonical Links AEO Tests', () => {
test('should have exactly one canonical link on each page', async ({ page }) => {
const pages = [
'/',
'/offline',
'/client-side',
'/exclude-similar',
'/privacy'
]
for (const pagePath of pages) {
await page.goto(pagePath)
// Find all canonical links
const canonicalLinks = await page.locator('link[rel="canonical"]').all()
// Should have exactly one canonical link
expect(canonicalLinks.length).toBe(1)
const canonicalHref = await canonicalLinks[0].getAttribute('href')
// Should have valid URL
expect(canonicalHref).toBeTruthy()
expect(() => new URL(canonicalHref!)).not.toThrow()
// Should use HTTPS
expect(canonicalHref).toMatch(/^https:\/\//)
// Should contain correct domain
expect(canonicalHref).toContain('passmaster.app')
// Should match expected path
const url = new URL(canonicalHref!)
if (pagePath === '/') {
expect(url.pathname).toBe('/')
} else {
expect(url.pathname).toBe(pagePath)
}
// Should not have trailing slash (except root)
if (pagePath !== '/') {
expect(url.pathname).not.toMatch(/\/$/)
}
// Should not have query parameters
expect(url.search).toBe('')
// Should not have fragment
expect(url.hash).toBe('')
console.log(`${pagePath}: ${canonicalHref}`)
}
})
test('should have canonical in metadata API', async ({ page }) => {
const pages = ['/', '/offline', '/client-side', '/exclude-similar']
for (const pagePath of pages) {
await page.goto(pagePath)
// Check if canonical is properly set in head
const canonical = await page.locator('head link[rel="canonical"]').first()
expect(canonical).toBeTruthy()
const href = await canonical.getAttribute('href')
expect(href).toBeTruthy()
expect(href).toMatch(/^https:\/\/passmaster\.app/)
}
})
test('should handle URL normalization correctly', async ({ page }) => {
// Test with various URL formats
const testCases = [
{ path: '/', expected: 'https://passmaster.app/' },
{ path: '/offline', expected: 'https://passmaster.app/offline' },
{ path: '/offline/', expected: 'https://passmaster.app/offline' }, // Should remove trailing slash
{ path: '/offline?utm_source=test', expected: 'https://passmaster.app/offline' }, // Should remove UTM params
]
for (const testCase of testCases) {
await page.goto(testCase.path)
const canonical = await page.locator('link[rel="canonical"]').first()
const href = await canonical.getAttribute('href')
expect(href).toBe(testCase.expected)
}
})
test('should not have multiple canonical declarations', async ({ page }) => {
const pages = ['/', '/offline', '/client-side', '/exclude-similar', '/privacy']
for (const pagePath of pages) {
await page.goto(pagePath)
// Check for canonical in link tags
const linkCanonicals = await page.locator('link[rel="canonical"]').all()
expect(linkCanonicals.length).toBeLessThanOrEqual(1)
// Check for canonical in HTTP headers (if any)
const response = await page.goto(pagePath)
const linkHeader = response?.headers()['link']
if (linkHeader) {
const canonicalInHeader = linkHeader.includes('rel="canonical"')
// If canonical in header, should not also be in HTML (or vice versa)
if (canonicalInHeader) {
expect(linkCanonicals.length).toBe(0)
}
}
}
})
test('should have consistent canonical URLs across navigation', async ({ page }) => {
// Navigate to page directly
await page.goto('/offline')
const directCanonical = await page.locator('link[rel="canonical"]').getAttribute('href')
// Navigate to page via homepage
await page.goto('/')
await page.click('a[href="/offline"]')
await page.waitForLoadState('networkidle')
const navigatedCanonical = await page.locator('link[rel="canonical"]').getAttribute('href')
// Should have same canonical URL regardless of how we arrived
expect(directCanonical).toBe(navigatedCanonical)
})
test('should handle special characters in URLs', async ({ page }) => {
await page.goto('/exclude-similar')
const canonical = await page.locator('link[rel="canonical"]').first()
const href = await canonical.getAttribute('href')
// Should properly encode URL
expect(href).toBeTruthy()
expect(() => new URL(href!)).not.toThrow()
const url = new URL(href!)
expect(url.pathname).toBe('/exclude-similar')
})
test('should not have self-referential canonical issues', async ({ page }) => {
const pages = ['/', '/offline', '/client-side']
for (const pagePath of pages) {
await page.goto(pagePath)
const canonical = await page.locator('link[rel="canonical"]').getAttribute('href')
const currentUrl = new URL(page.url())
const canonicalUrl = new URL(canonical!)
// Canonical should match current page path (normalized)
expect(canonicalUrl.pathname).toBe(currentUrl.pathname === '/' ? '/' : currentUrl.pathname.replace(/\/$/, ''))
expect(canonicalUrl.hostname).toBe('passmaster.app') // Should use production domain
}
})
})

87
tests/e2e/robots.spec.ts Normal file
View File

@@ -0,0 +1,87 @@
import { test, expect } from '@playwright/test'
test.describe('Robots.txt AEO Tests', () => {
test('should allow PerplexityBot and GPTBot', async ({ page }) => {
const response = await page.goto('/robots.txt')
expect(response?.status()).toBe(200)
const content = await page.textContent('body')
expect(content).toBeTruthy()
// Check for PerplexityBot allowance
expect(content).toContain('User-agent: PerplexityBot')
expect(content).toMatch(/User-agent:\s*PerplexityBot[\s\S]*?Allow:\s*\//)
// Check for GPTBot allowance
expect(content).toContain('User-agent: GPTBot')
expect(content).toMatch(/User-agent:\s*GPTBot[\s\S]*?Allow:\s*\//)
// Check for ChatGPT-User allowance
expect(content).toContain('User-agent: ChatGPT-User')
expect(content).toMatch(/User-agent:\s*ChatGPT-User[\s\S]*?Allow:\s*\//)
// Check for Claude-Web allowance
expect(content).toContain('User-agent: Claude-Web')
expect(content).toMatch(/User-agent:\s*Claude-Web[\s\S]*?Allow:\s*\//)
// Check for sitemap reference
expect(content).toMatch(/Sitemap:\s*https?:\/\/[^\s]+\/sitemap\.xml/)
// Ensure no blanket disallow that would block answer engines
const lines = content?.split('\n') || []
const userAgentWildcardSections = []
let currentSection = []
let inWildcardSection = false
for (const line of lines) {
const trimmedLine = line.trim()
if (trimmedLine.startsWith('User-agent:')) {
if (currentSection.length > 0) {
userAgentWildcardSections.push(currentSection)
}
currentSection = [trimmedLine]
inWildcardSection = trimmedLine.includes('*')
} else if (trimmedLine.startsWith('Disallow:') || trimmedLine.startsWith('Allow:')) {
currentSection.push(trimmedLine)
}
}
if (currentSection.length > 0) {
userAgentWildcardSections.push(currentSection)
}
// Check wildcard sections don't have blanket disallow
for (const section of userAgentWildcardSections) {
if (section[0].includes('*')) {
const hasBlankDisallow = section.some(line =>
line.trim() === 'Disallow: /' || line.trim() === 'Disallow:'
)
expect(hasBlankDisallow).toBeFalsy()
}
}
})
test('should have proper content type and encoding', async ({ page }) => {
const response = await page.goto('/robots.txt')
expect(response?.status()).toBe(200)
expect(response?.headers()['content-type']).toContain('text/plain')
})
test('should be accessible to crawlers', async ({ page }) => {
// Simulate different user agents
const userAgents = [
'PerplexityBot/1.0',
'Mozilla/5.0 (compatible; GPTBot/1.0; +https://openai.com/gptbot)',
'Mozilla/5.0 (compatible; ChatGPT-User/1.0; +https://openai.com/chatgpt)',
'Mozilla/5.0 (compatible; Claude-Web/1.0; +https://anthropic.com)'
]
for (const userAgent of userAgents) {
await page.setUserAgent(userAgent)
const response = await page.goto('/robots.txt')
expect(response?.status()).toBe(200)
}
})
})

224
tests/e2e/schema.spec.ts Normal file
View File

@@ -0,0 +1,224 @@
import { test, expect } from '@playwright/test'
test.describe('JSON-LD Schema AEO Tests', () => {
test('should have valid JSON-LD on homepage', async ({ page }) => {
await page.goto('/')
// Find all JSON-LD script tags
const jsonLdScripts = await page.locator('script[type="application/ld+json"]').all()
expect(jsonLdScripts.length).toBeGreaterThan(0)
// Check each JSON-LD script for valid JSON
for (const script of jsonLdScripts) {
const content = await script.textContent()
expect(content).toBeTruthy()
let jsonData
expect(() => {
jsonData = JSON.parse(content!)
}).not.toThrow()
// Should have @context and @type
expect(jsonData).toHaveProperty('@context')
expect(jsonData).toHaveProperty('@type')
expect(jsonData['@context']).toBe('https://schema.org')
}
})
test('should have SoftwareApplication schema on homepage', async ({ page }) => {
await page.goto('/')
const jsonLdScripts = await page.locator('script[type="application/ld+json"]').all()
let hasSoftwareApplication = false
for (const script of jsonLdScripts) {
const content = await script.textContent()
const jsonData = JSON.parse(content!)
if (jsonData['@type'] === 'SoftwareApplication') {
hasSoftwareApplication = true
// Validate required fields
expect(jsonData).toHaveProperty('name')
expect(jsonData).toHaveProperty('description')
expect(jsonData).toHaveProperty('applicationCategory')
expect(jsonData).toHaveProperty('operatingSystem')
expect(jsonData).toHaveProperty('offers')
expect(jsonData).toHaveProperty('isAccessibleForFree')
// Check offers structure
expect(jsonData.offers).toHaveProperty('@type', 'Offer')
expect(jsonData.offers).toHaveProperty('price')
expect(jsonData.offers).toHaveProperty('priceCurrency')
}
}
expect(hasSoftwareApplication).toBeTruthy()
})
test('should have Organization schema', async ({ page }) => {
await page.goto('/')
const jsonLdScripts = await page.locator('script[type="application/ld+json"]').all()
let hasOrganization = false
for (const script of jsonLdScripts) {
const content = await script.textContent()
const jsonData = JSON.parse(content!)
if (jsonData['@type'] === 'Organization') {
hasOrganization = true
// Validate required fields
expect(jsonData).toHaveProperty('name')
expect(jsonData).toHaveProperty('url')
expect(jsonData).toHaveProperty('logo')
// Check logo structure
if (typeof jsonData.logo === 'object') {
expect(jsonData.logo).toHaveProperty('@type', 'ImageObject')
expect(jsonData.logo).toHaveProperty('url')
}
}
}
expect(hasOrganization).toBeTruthy()
})
test('should have WebSite schema with SearchAction', async ({ page }) => {
await page.goto('/')
const jsonLdScripts = await page.locator('script[type="application/ld+json"]').all()
let hasWebSite = false
for (const script of jsonLdScripts) {
const content = await script.textContent()
const jsonData = JSON.parse(content!)
if (jsonData['@type'] === 'WebSite') {
hasWebSite = true
// Validate required fields
expect(jsonData).toHaveProperty('name')
expect(jsonData).toHaveProperty('url')
// Check for SearchAction
if (jsonData.potentialAction) {
expect(jsonData.potentialAction).toHaveProperty('@type', 'SearchAction')
expect(jsonData.potentialAction).toHaveProperty('target')
expect(jsonData.potentialAction).toHaveProperty('query-input')
}
}
}
expect(hasWebSite).toBeTruthy()
})
test('should have FAQPage schema on FAQ pages', async ({ page }) => {
await page.goto('/')
const jsonLdScripts = await page.locator('script[type="application/ld+json"]').all()
let hasFAQPage = false
for (const script of jsonLdScripts) {
const content = await script.textContent()
const jsonData = JSON.parse(content!)
if (jsonData['@type'] === 'FAQPage') {
hasFAQPage = true
// Validate structure
expect(jsonData).toHaveProperty('mainEntity')
expect(Array.isArray(jsonData.mainEntity)).toBeTruthy()
expect(jsonData.mainEntity.length).toBeGreaterThan(0)
// Check first FAQ item
const firstFaq = jsonData.mainEntity[0]
expect(firstFaq).toHaveProperty('@type', 'Question')
expect(firstFaq).toHaveProperty('name')
expect(firstFaq).toHaveProperty('acceptedAnswer')
// Check answer structure
expect(firstFaq.acceptedAnswer).toHaveProperty('@type', 'Answer')
expect(firstFaq.acceptedAnswer).toHaveProperty('text')
}
}
expect(hasFAQPage).toBeTruthy()
})
test('should have Article schema on content pages', async ({ page }) => {
const contentPages = ['/offline', '/client-side', '/exclude-similar']
for (const pagePath of contentPages) {
await page.goto(pagePath)
const jsonLdScripts = await page.locator('script[type="application/ld+json"]').all()
for (const script of jsonLdScripts) {
const content = await script.textContent()
const jsonData = JSON.parse(content!)
if (jsonData['@type'] === 'Article' || jsonData['@type'] === 'TechArticle') {
// Validate article fields
expect(jsonData).toHaveProperty('headline')
expect(jsonData).toHaveProperty('description')
expect(jsonData).toHaveProperty('author')
// Check author structure
if (jsonData.author) {
expect(jsonData.author).toHaveProperty('@type')
expect(jsonData.author).toHaveProperty('name')
}
// Check dates if present
if (jsonData.datePublished) {
expect(() => new Date(jsonData.datePublished)).not.toThrow()
}
if (jsonData.dateModified) {
expect(() => new Date(jsonData.dateModified)).not.toThrow()
}
}
}
}
})
test('should validate JSON-LD syntax', async ({ page }) => {
const pages = ['/', '/offline', '/client-side', '/exclude-similar', '/privacy']
for (const pagePath of pages) {
await page.goto(pagePath)
const jsonLdScripts = await page.locator('script[type="application/ld+json"]').all()
for (const script of jsonLdScripts) {
const content = await script.textContent()
// Should be valid JSON
let jsonData
expect(() => {
jsonData = JSON.parse(content!)
}).not.toThrow()
// Should have schema.org context
expect(jsonData).toHaveProperty('@context')
expect(jsonData['@context']).toContain('schema.org')
// Should have valid type
expect(jsonData).toHaveProperty('@type')
expect(typeof jsonData['@type']).toBe('string')
// No empty required fields
const requiredFields = ['name', 'headline', 'title', 'text']
for (const field of requiredFields) {
if (jsonData[field] !== undefined) {
expect(jsonData[field]).not.toBe('')
expect(jsonData[field]).not.toBeNull()
}
}
}
}
})
})