SEO + AEO
This commit is contained in:
161
tests/e2e/canonical.spec.ts
Normal file
161
tests/e2e/canonical.spec.ts
Normal 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
87
tests/e2e/robots.spec.ts
Normal 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
224
tests/e2e/schema.spec.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user