161 lines
5.5 KiB
TypeScript
161 lines
5.5 KiB
TypeScript
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
|
|
}
|
|
})
|
|
}) |