Files
vue-seo-layer/tests/seo.test.ts
Jakub Zych 160b53b2f5 feat: CMS head and one JSON-LD graph on every server-rendered page (tracer)
- Nitro middleware resolves page requests once and hands the answer to the
  render (event.context.seo); the route middleware reuses it
- useCmsSeo: html lang, hreflang links, og:locale and exactly one JSON-LD
  script with one @graph (CMS nodes plus useSchemaNode nodes, merged by @id)
- local module (configKey seo): cms (alias of public.seo.enabled),
  breadcrumbs, links.newTabHint, rootFiles, resolveFiles
- GET /api/__seo__/resolve for client navigation through the Nuxt server
- smoke harness: count=, exact header=, noheader=, reqheader=, repeated
  options, resolves/apicalls/noreport lines, --no-site; v0.1.1 suite runs
  unchanged against a backend without /site
2026-11-04 09:15:26 +01:00

168 lines
7.9 KiB
TypeScript

import assert from 'node:assert/strict'
import test from 'node:test'
import {
SEO_STAGING_ROBOTS,
seoAbsolute,
seoAnswer,
seoEnabled,
seoHeadInput,
seoRedirectTarget,
seoResolvableUrl,
seoRobots,
seoVerificationName,
seoXRobotsTag,
} from '../app/utils/seo.ts'
import type { SeoResolveAnswer } from '../app/utils/types.ts'
const okAnswer = (over: Partial<SeoResolveAnswer['seo']> = {}, staging = false): SeoResolveAnswer => ({
action: 'ok',
status: 200,
url: 'https://acme.test/about',
locale: 'en',
staging,
seo: {
title: 'About | Acme',
description: 'Who we are',
canonical: 'https://acme.test/about',
robots: 'index, follow',
x_robots_tag: 'index, follow',
og: { title: 'About | Acme', description: 'Who we are', image: 'https://acme.test/a.png', url: 'https://acme.test/about' },
twitter: { card: 'summary_large_image', site: '@acme' },
verification: [],
...over,
},
})
test('resolvable URLs drop the hash and skip internal paths', () => {
assert.equal(seoResolvableUrl('/blog?page=2#top'), '/blog?page=2')
assert.equal(seoResolvableUrl('/'), '/')
for (const p of ['/_nuxt/a.js', '/__nuxt_error', '/_journal/api/v1/posts', '/api/x', '/api', '//evil.test/x', 'x']) {
assert.equal(seoResolvableUrl(p), null, p)
}
})
test('seoAnswer accepts the wire shape and rejects anything else', () => {
assert.equal(seoAnswer(null), null)
assert.equal(seoAnswer({ action: 'weird', status: 200 }), null)
assert.equal(seoAnswer({ action: 'ok' }), null)
assert.equal(seoAnswer({ action: 'ok', status: 200, seo: 'x' }), null)
const a = seoAnswer({ action: 'not_found', status: 404, url: '/x', staging: true })
assert.deepEqual(a && { action: a.action, staging: a.staging, seo: a.seo }, { action: 'not_found', staging: true, seo: null })
})
test('a redirect on the site origin becomes a path, another host stays external', () => {
const base = { action: 'redirect' as const, url: 'https://acme.test/old?x=1', locale: '', staging: false, seo: null }
assert.deepEqual(seoRedirectTarget({ ...base, status: 301, location: 'https://acme.test/new?x=1' }), { to: '/new?x=1', external: false, code: 301 })
assert.deepEqual(seoRedirectTarget({ ...base, status: 302, location: '/tmp' }), { to: '/tmp', external: false, code: 302 })
assert.deepEqual(seoRedirectTarget({ ...base, status: 301, location: 'https://other.test/p' }), { to: 'https://other.test/p', external: true, code: 301 })
assert.equal(seoRedirectTarget({ ...base, status: 301, location: 'javascript:alert(1)' }), null)
assert.equal(seoRedirectTarget({ ...base, status: 301 }), null)
assert.equal(seoRedirectTarget(okAnswer()), null)
})
test('robots: staging wins, ok answers carry their own, other answers none', () => {
assert.equal(seoRobots(okAnswer({}, true)), SEO_STAGING_ROBOTS)
assert.equal(seoRobots(okAnswer()), 'index, follow')
assert.equal(seoRobots({ action: 'not_found', status: 404, url: '', locale: '', staging: false, seo: null }), '')
assert.equal(seoRobots({ action: 'not_found', status: 404, url: '', locale: '', staging: true, seo: null }), SEO_STAGING_ROBOTS)
assert.equal(seoRobots(null), '')
assert.equal(seoXRobotsTag(okAnswer({}, true)), SEO_STAGING_ROBOTS)
assert.equal(seoXRobotsTag(okAnswer()), 'index, follow')
})
test('the head of an ok answer has every value and a final title', () => {
const head = seoHeadInput(okAnswer({ verification: [{ name: 'google-site-verification', content: 'abc' }] }))
assert.equal(head.title, 'About | Acme')
assert.equal(head.titleTemplate, '%s')
assert.deepEqual(head.link, [{ rel: 'canonical', href: 'https://acme.test/about' }])
const meta = Object.fromEntries(head.meta.map((m) => [m.name ?? m.property, m.content]))
assert.deepEqual(meta, {
robots: 'index, follow',
description: 'Who we are',
'og:title': 'About | Acme',
'og:description': 'Who we are',
'og:image': 'https://acme.test/a.png',
'og:url': 'https://acme.test/about',
'twitter:card': 'summary_large_image',
'twitter:site': '@acme',
'google-site-verification': 'abc',
})
})
test('empty values leave the page its own tags', () => {
const head = seoHeadInput(okAnswer({ title: '', description: '', canonical: '', og: { title: '', description: '', image: '', url: '' }, twitter: { card: '', site: '' } }))
assert.equal(head.title, undefined)
assert.equal(head.titleTemplate, undefined)
assert.deepEqual(head.link, [])
assert.deepEqual(head.meta, [{ name: 'robots', content: 'index, follow' }])
assert.deepEqual(seoHeadInput(null), { meta: [], link: [] })
assert.deepEqual(seoHeadInput({ action: 'not_found', status: 404, url: '', locale: '', staging: true, seo: null }), {
meta: [{ name: 'robots', content: SEO_STAGING_ROBOTS }],
link: [],
})
})
test('verification names: one segment, three extensions, never a reserved name', () => {
const reserved = ['robots.txt', 'sitemap.xml', 'rss.xml']
assert.equal(seoVerificationName('/google0123abc.html', reserved), 'google0123abc.html')
assert.equal(seoVerificationName('/BingSiteAuth.xml?x=1', reserved), 'BingSiteAuth.xml')
for (const p of ['/robots.txt', '/Sitemap.xml', '/rss.xml', '/a/b.html', '/x.php', '/.env.txt', '/', '/x..']) {
assert.equal(seoVerificationName(p, reserved), null, p)
}
})
test('paths in canonical and social URLs become absolute on the request origin', () => {
assert.equal(seoAbsolute('/a?b=1', 'https://acme.test'), 'https://acme.test/a?b=1')
assert.equal(seoAbsolute('/a', 'https://acme.test/'), 'https://acme.test/a')
assert.equal(seoAbsolute('https://x.test/a', 'https://acme.test'), 'https://x.test/a')
assert.equal(seoAbsolute('//x.test/a', 'https://acme.test'), '//x.test/a')
assert.equal(seoAbsolute('/a', undefined), '/a')
const head = seoHeadInput(okAnswer({ canonical: '/about', og: { title: '', description: '', image: '/img.png', url: '/about' } }), 'https://site.test')
assert.deepEqual(head.link, [{ rel: 'canonical', href: 'https://site.test/about' }])
const meta = Object.fromEntries(head.meta.map((m) => [m.name ?? m.property, m.content]))
assert.equal(meta['og:image'], 'https://site.test/img.png')
assert.equal(meta['og:url'], 'https://site.test/about')
})
test('seoHeadInput: v0.2.0 keys add html lang, og:locale and hreflang links', () => {
const answer = {
...okAnswer({ og: { title: '', description: '', image: '', url: '', locale: 'pl_PL', locale_alternates: ['en_US', 'de_DE'] } }),
html_lang: 'pl',
alternates: [
{ hreflang: 'pl', url: 'https://acme.test/about' },
{ hreflang: 'x-default', url: '/about' },
],
}
const head = seoHeadInput(answer, 'https://site.test')
assert.deepEqual(head.htmlAttrs, { lang: 'pl' })
assert.ok(head.meta.some((m) => m.property === 'og:locale' && m.content === 'pl_PL'))
assert.deepEqual(
head.meta.filter((m) => m.property === 'og:locale:alternate').map((m) => m.content),
['en_US', 'de_DE'],
)
assert.deepEqual(
head.link.filter((l) => l.rel === 'alternate'),
[
{ rel: 'alternate', hreflang: 'pl', href: 'https://acme.test/about', key: 'alt-pl' },
{ rel: 'alternate', hreflang: 'x-default', href: 'https://site.test/about', key: 'alt-x-default' },
],
)
})
test('seoHeadInput: an answer without the v0.2.0 keys gives the v0.1.1 head', () => {
const head = seoHeadInput(okAnswer(), 'https://site.test')
assert.equal(head.htmlAttrs, undefined)
assert.ok(!head.meta.some((m) => m.property?.startsWith('og:locale')))
assert.ok(!head.link.some((l) => l.rel === 'alternate'))
})
test('seoEnabled: enabled and its alias cms, booleans or strings', () => {
assert.equal(seoEnabled(undefined), true)
assert.equal(seoEnabled({}), true)
assert.equal(seoEnabled({ enabled: true, cms: true }), true)
assert.equal(seoEnabled({ enabled: false }), false)
assert.equal(seoEnabled({ enabled: 'false' }), false)
assert.equal(seoEnabled({ cms: false }), false)
assert.equal(seoEnabled({ enabled: true, cms: 'false' }), false)
})