feat: SEO SPA editor integration

This commit is contained in:
Florian Sylvain committed 2026-10-16 03:06:57 +02:00
1 parent eb9876fde3
commit 470584400f
62 files changed
+7471 -58

No files matched your search

@@ -0,0 +1,571 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { click, textOf, unmountMeta } from './metaTestKit'
import { blur, typeInto } from './directoryTestKit'
import {
ARTICLE_SLUG,
ARTICLE_TITLE,
FIRST_SENTENCE,
LONG_TITLE,
answerChecks,
checkAnswer,
checkBodies,
counterTexts,
fieldByLabel,
mountSeo,
openDisclosure,
openPanel,
openSection,
panelRoot,
patchBodies,
renewSite,
saveChangesButton,
seoBackend,
suggestion,
} from './seoTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
})
afterEach(async () => {
vi.useRealTimers()
await unmountMeta()
})
function panelText() {
return textOf(panelRoot()!)
}
function chips(root: ParentNode = panelRoot()!) {
return Array.from(root.querySelectorAll('[data-testid="fallback-chip"]')).map((chip) =>
textOf(chip),
)
}
function switchElement() {
return panelRoot()!.querySelector<HTMLElement>('button[role="switch"]')!
}
function filledArticle() {
return {
seo_title: 'What a bathroom renovation costs',
seo_description:
'A bathroom renovation is priced by room size, plumbing changes and finish level. Here is how we build a quote.',
excerpt: 'How we price a bathroom renovation, from the survey to the final invoice.',
cover_image_id: 1,
cover_image_alt: 'A tiled bathroom wall during renovation',
}
}
describe('Opening', () => {
it('opens a 960 px slide-over on desktop with the fields, the previews, Cancel and Save changes', async () => {
await mountSeo(seoBackend())
const panel = await openPanel()
expect(panel.className).toContain('max-w-240')
expect(textOf(panel.querySelector('h2, [data-slot="title"]'))).toBe('Search and sharing')
expect(panelText()).toContain('Search result')
expect(panelText()).toContain('Social card')
expect(panelText()).toContain('Cancel')
expect(textOf(saveChangesButton(panel))).toBe('Save changes')
})
it('opens a sheet on mobile with the suggestions first, then the fields, then the two previews', async () => {
await mountSeo(seoBackend(), { width: MOBILE_WIDTH })
await click('Details')
const sheet = document.body.querySelector<HTMLElement>('[role="dialog"]')!
sheet.querySelector<HTMLElement>('[data-testid="seo-toggle"]')!.click()
await flushPromises()
sheet.querySelector<HTMLElement>('[data-testid="seo-edit"]')!.click()
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 0))
await flushPromises()
const panel = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="dialog"]'),
).find((dialog) => dialog.querySelector('[data-testid="seo-panel-suggestions"]'))!
expect(panel.className).not.toContain('max-w-240')
expect(panel.className).toContain('h-23/25')
const text = textOf(panel)
const positions = ['All good', 'URL slug', 'Search result', 'Social card'].map((part) =>
text.indexOf(part),
)
expect(Math.min(...positions)).toBeGreaterThanOrEqual(0)
expect(positions).toEqual([...positions].sort((a, b) => a - b))
})
})
describe('Defaults, every value is a fallback and says so', () => {
it('shows the fallback of every field and its chip for an article with no SEO field set', async () => {
const backend = seoBackend({ sites: [renewSite({ default_social_image_id: 1 })] })
await mountSeo(backend)
await openPanel()
const text = panelText()
expect(text).toContain('URL slug')
expect(text).toContain('Auto')
expect(text).toContain('Generated from the title until you edit it.')
expect(text).toContain('Meta title')
expect(text).toContain('Recommended 30 to 60 characters. This is a guide, not a limit.')
expect(text).toContain('Meta description')
expect(text).toContain('Recommended 70 to 160 characters.')
expect(text).toContain('Excerpt')
expect(text).toContain('Using the site social image (north-wall.jpg).')
expect(text).toContain('Social sharing')
expect(text).toContain('Optional')
expect(text).toContain('Uses the meta values')
expect(text).toContain('Advanced')
expect(text).toContain('Canonical URL: automatic')
expect(text).toContain('Hide from search engines')
expect(text).toContain(
"Sets noindex in the API. It doesn't change who can read the article.",
)
expect(text).toContain('Suggestions')
expect(text).toContain('What the frontend gets')
expect(chips()).toContain('Using article title')
expect(chips()).toContain('Using first sentence of the body')
expect(switchElement().getAttribute('aria-checked')).toBe('false')
expect(fieldByLabel(panelRoot()!, 'Meta title')!.placeholder).toBe(ARTICLE_TITLE)
expect(fieldByLabel(panelRoot()!, 'Meta description')!.placeholder).toBe(FIRST_SENTENCE)
expect(fieldByLabel(panelRoot()!, 'Excerpt')!.placeholder).toBe(
'A short summary. It is also sent as the article teaser.',
)
})
it('shows the excerpt chip when there is an excerpt but no meta description', async () => {
await mountSeo(seoBackend({ article: { excerpt: 'An excerpt long enough to be used.' } }))
await openPanel()
expect(chips()).toContain('Using the excerpt')
expect(chips()).not.toContain('Using first sentence of the body')
})
it('shows the site default description chip when there is no description, excerpt or body text', async () => {
const backend = seoBackend({
article: { body: '' },
sites: [renewSite({ default_description: 'One or two sentences about the site.' })],
})
await mountSeo(backend)
await openPanel()
expect(chips()).toContain('Using the site default description')
})
it('shows the No image chip on the social card when there is no cover and no site social image', async () => {
await mountSeo(seoBackend())
await openPanel()
expect(chips(panelRoot()!.querySelector('[data-testid="social-card"]')!)).toContain(
'No image',
)
})
it('drops the fallback chips and shows All good when the article has its own values', async () => {
await mountSeo(seoBackend({ article: filledArticle() }))
const panel = await openPanel()
const fieldChips = chips(panel).filter(
(chip) => !['Using meta title', 'Using meta description'].includes(chip),
)
expect(fieldChips).not.toContain('Using article title')
expect(fieldChips).not.toContain('Using first sentence of the body')
expect(fieldChips).not.toContain('Using the excerpt')
expect(textOf(panel.querySelector('[data-testid="seo-panel-suggestions"]'))).toContain(
'All good Nothing to suggest for this article.',
)
})
})
describe('Soft counters are a guide', () => {
it.each([
{ field: 'Meta title', n: 29, max: 60, amber: true },
{ field: 'Meta title', n: 30, max: 60, amber: false },
{ field: 'Meta title', n: 60, max: 60, amber: false },
{ field: 'Meta title', n: 74, max: 60, amber: true },
{ field: 'Meta description', n: 41, max: 160, amber: true },
{ field: 'Meta description', n: 70, max: 160, amber: false },
{ field: 'Meta description', n: 160, max: 160, amber: false },
{ field: 'Meta description', n: 161, max: 160, amber: true },
])('reads $n / $max for the $field and amber is $amber', async ({ field, n, max, amber }) => {
await mountSeo(seoBackend())
const panel = await openPanel()
await typeInto(fieldByLabel(panel, field)!, 'x'.repeat(n))
const counter = Array.from(
panel.querySelectorAll<HTMLElement>('[data-testid="soft-counter"]'),
).find((node) => textOf(node) === `${n} / ${max}`)!
expect(counter).toBeInstanceOf(HTMLElement)
expect(counter.className).toContain('font-mono')
expect(counter.className).toContain(amber ? 'text-warning' : 'text-muted')
expect(counter.className).not.toContain('text-error')
expect(saveChangesButton(panel).disabled).toBe(false)
})
it('turns both counters amber, never red, outside the soft ranges, and cuts the preview off', async () => {
const backend = seoBackend()
answerChecks(
backend,
checkAnswer([
suggestion('title_long', 'seo_title', { length: 74, max: 60 }),
suggestion('desc_short', 'seo_description', { length: 33, min: 70 }),
]),
)
await mountSeo(backend)
const panel = await openPanel()
await typeInto(fieldByLabel(panel, 'Meta title')!, LONG_TITLE)
await typeInto(
fieldByLabel(panel, 'Meta description')!,
'What a bathroom renovation costs.',
)
const counters = Array.from(
panel.querySelectorAll<HTMLElement>('[data-testid="soft-counter"]'),
).slice(0, 2)
expect(counters.map((node) => node.className)).toEqual([
expect.stringContaining('text-warning'),
expect.stringContaining('text-warning'),
])
expect(counters.some((node) => node.className.includes('text-error'))).toBe(false)
const suggestions = textOf(panel.querySelector('[data-testid="seo-panel-suggestions"]'))
expect(suggestions).toContain('The meta title is 74 characters.')
expect(suggestions).toContain('The description is 33 characters.')
expect(panelText()).toContain('Cut off here, the way a search engine would.')
})
})
describe('The API length limits are not reachable', () => {
it('stops each input at the limit the API refuses beyond', async () => {
await mountSeo(seoBackend({ article: { cover_image_id: 1 } }))
const panel = await openPanel()
await openDisclosure('Social sharing')
await openDisclosure('Advanced')
const limits: Record<string, string> = {
'Meta title': '120',
'Meta description': '320',
'Alt text': '200',
'Social title': '120',
'Social description': '320',
'Canonical URL': '2048',
'URL slug': '100',
}
for (const [label, max] of Object.entries(limits)) {
expect(fieldByLabel(panel, label)!.getAttribute('maxlength'), label).toBe(max)
}
})
})
describe('Excerpt over 300', () => {
it('shows an inline error and disables Save changes until it is 300 or fewer, sending nothing for it', async () => {
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const excerpt = fieldByLabel(panel, 'Excerpt')!
await typeInto(excerpt, 'x'.repeat(301))
await vi.advanceTimersByTimeAsync(600)
await flushPromises()
expect(panelText()).toContain('Shorten the excerpt to 300 characters or fewer.')
expect(panelText()).toContain('301 / 300')
expect(saveChangesButton(panel).disabled).toBe(true)
expect(checkBodies(backend).every((body) => !('excerpt' in (body ?? {})))).toBe(true)
expect(patchBodies(backend)).toEqual([])
await typeInto(excerpt, 'x'.repeat(300))
expect(saveChangesButton(panel).disabled).toBe(false)
expect(panelText()).not.toContain('Shorten the excerpt')
})
})
describe('Slug edited, Auto and Reset', () => {
it('drops the Auto badge and offers Reset when the pencil is used, and Reset brings Auto back', async () => {
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
expect(panelText()).toContain('Auto')
panel.querySelector<HTMLElement>('button[aria-label="Edit slug"]')!.click()
await flushPromises()
const slug = fieldByLabel(panel, 'URL slug')!
expect(panelText()).not.toContain('Generated from the title until you edit it.')
expect(slug.readOnly).toBe(false)
expect(slug.value).toBe(ARTICLE_SLUG)
expect(panelText()).toContain('Reset to match the title')
await typeInto(slug, 'bathroom-costs')
expect(slug.value).toBe('bathroom-costs')
await click('Reset to match the title')
expect(fieldByLabel(panel, 'URL slug')!.value).toBe(ARTICLE_SLUG)
expect(panelText()).toContain('Auto')
expect(panelText()).not.toContain('Reset to match the title')
void backend
})
it('sends the slug as an empty string when a custom slug is reset, and checks it', async () => {
const backend = await mountSeo(seoBackend({ article: { slug: 'bathroom-costs' } }))
const panel = await openPanel()
expect(fieldByLabel(panel, 'URL slug')!.value).toBe('bathroom-costs')
expect(panelText()).toContain('Reset to match the title')
await click('Reset to match the title')
expect(fieldByLabel(panel, 'URL slug')!.value).toBe(ARTICLE_SLUG)
expect(checkBodies(backend).at(-1)).toEqual({ slug: '' })
saveChangesButton(panel).click()
await flushPromises()
expect(patchBodies(backend)).toEqual([{ slug: '' }])
})
it('normalises what is typed to lowercase letters, numbers and hyphens', async () => {
await mountSeo(seoBackend())
const panel = await openPanel()
panel.querySelector<HTMLElement>('button[aria-label="Edit slug"]')!.click()
await flushPromises()
await typeInto(fieldByLabel(panel, 'URL slug')!, 'Bathroom Costs!')
expect(fieldByLabel(panel, 'URL slug')!.value).toBe('bathroom-costs-')
})
})
describe('Cover and alt text', () => {
async function pickNorthWall(panel: HTMLElement) {
const choose = Array.from(panel.querySelectorAll<HTMLButtonElement>('button')).find(
(button) => textOf(button) === 'Choose image',
)!
choose.click()
await flushPromises()
const dialogs = Array.from(document.body.querySelectorAll<HTMLElement>('[role="dialog"]'))
const picker = dialogs.at(-1)!
return picker
}
it('opens the shared picker titled Choose a cover image with Use image, and shows the alt field once a cover is picked', async () => {
await mountSeo(seoBackend())
const panel = await openPanel()
const picker = await pickNorthWall(panel)
expect(textOf(picker)).toContain('Choose a cover image')
expect(textOf(picker)).toContain('The cover for this article.')
picker.querySelector<HTMLElement>('[data-picker-tile]')!.click()
await flushPromises()
Array.from(picker.querySelectorAll<HTMLButtonElement>('button'))
.find((button) => textOf(button) === 'Use image')!
.click()
await flushPromises()
expect(panelText()).toContain('north-wall.jpg')
const alt = fieldByLabel(panel, 'Alt text')!
expect(alt.placeholder).toBe("Describe the image for people who can't see it")
expect(panelText()).toContain(
'Needed before the cover counts as ready. Sent as image alt in the API.',
)
})
it('brings the site social image line back and sends a null cover on save when the cover is removed', async () => {
const backend = await mountSeo(
seoBackend({
article: { cover_image_id: 1, cover_image_alt: 'A wall' },
sites: [renewSite({ default_social_image_id: 2 })],
}),
)
const panel = await openPanel()
expect(panelText()).not.toContain('Using the site social image')
await click('Remove', panel)
expect(panelText()).toContain('Using the site social image (kitchen-after.webp).')
saveChangesButton(panel).click()
await flushPromises()
expect(patchBodies(backend)).toEqual([{ cover_image_id: null }])
})
})
describe('Social sharing', () => {
it('shows the Using chips while nothing is set, and Customised with the overrides on the card once it is', async () => {
await mountSeo(seoBackend())
const panel = await openPanel()
await openDisclosure('Social sharing')
expect(chips()).toEqual(
expect.arrayContaining(['Using meta title', 'Using meta description']),
)
expect(panelText()).toContain('Using the cover image.')
expect(panelText()).not.toContain('Customised')
await typeInto(fieldByLabel(panel, 'Social title')!, 'Bathroom costs, explained')
await typeInto(
fieldByLabel(panel, 'Social description')!,
'A plain guide to what drives the price.',
)
const card = panel.querySelector<HTMLElement>('[data-testid="social-card"]')!
expect(textOf(card.querySelector('[data-testid="social-card-title"]'))).toBe(
'Bathroom costs, explained',
)
expect(textOf(card.querySelector('[data-testid="social-card-description"]'))).toBe(
'A plain guide to what drives the price.',
)
expect(chips()).not.toContain('Using meta title')
expect(chips()).not.toContain('Using meta description')
expect(panelText()).toContain('Customised')
})
})
describe('Advanced, canonical URL', () => {
it.each([
{ value: 'renew.example/costs', error: true },
{ value: 'http://renew.example/a', error: true },
{ value: 'https://renew.example/a', error: false },
{ value: '', error: false },
])('for "$value" the error is shown: $error', async ({ value, error }) => {
await mountSeo(seoBackend())
const panel = await openPanel()
await openDisclosure('Advanced')
const canonical = fieldByLabel(panel, 'Canonical URL')!
await typeInto(canonical, value)
await blur(canonical)
const message = 'Enter a full address starting with https://'
expect(panelText().includes(message)).toBe(error)
expect(canonical.getAttribute('aria-invalid') === 'true').toBe(error)
})
it('shows Canonical URL: automatic when collapsed and empty', async () => {
await mountSeo(seoBackend())
await openPanel()
expect(panelText()).toContain('Canonical URL: automatic')
})
})
describe('Hide from search engines', () => {
it('keeps the helper visible and turns the switch on, and the next check answers hidden', async () => {
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
answerChecks(backend, checkAnswer([], 'hidden'))
switchElement().click()
await flushPromises()
expect(switchElement().getAttribute('aria-checked')).toBe('true')
expect(textOf(panel)).toContain(
"Sets noindex in the API. It doesn't change who can read the article.",
)
expect(checkBodies(backend).at(-1)).toEqual({ noindex: true })
expect(textOf(document.body.querySelector('[data-testid="seo-body"]'))).toContain(
'Hidden from search engines. This only sets a flag in the API.',
)
})
})
describe('The search result preview', () => {
it.each([
{ device: 'Desktop', title: 60, description: 160 },
{ device: 'Mobile', title: 70, description: 120 },
])(
'cuts the title at $title and the description at $description on $device',
async (limits) => {
await mountSeo(
seoBackend({
article: { seo_title: LONG_TITLE, seo_description: 'd'.repeat(200) },
}),
)
const panel = await openPanel()
const tab = Array.from(panel.querySelectorAll<HTMLElement>('[role="tab"]')).find(
(candidate) => textOf(candidate) === limits.device,
)!
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
tab.click()
await flushPromises()
const title = textOf(panel.querySelector('[data-testid="search-preview-title"]'))
const description = textOf(
panel.querySelector('[data-testid="search-preview-description"]'),
)
expect([...title]).toHaveLength(limits.title)
expect(title.endsWith('…')).toBe(true)
expect([...description]).toHaveLength(limits.description)
expect(description.endsWith('…')).toBe(true)
const card = panel.querySelector<HTMLElement>('[data-testid="search-preview-card"]')!
expect(card.className.includes('md:w-70')).toBe(limits.device === 'Mobile')
},
)
})
describe('The previews follow the typing, with the fallbacks', () => {
it('shows the article title with the site title template when the meta title is cleared', async () => {
await mountSeo(seoBackend({ article: { seo_title: 'Typed meta title' } }))
const panel = await openPanel()
expect(textOf(panel.querySelector('[data-testid="search-preview-title"]'))).toBe(
'Typed meta title | Renew',
)
await typeInto(fieldByLabel(panel, 'Meta title')!, '')
expect(textOf(panel.querySelector('[data-testid="search-preview-title"]'))).toBe(
`${ARTICLE_TITLE} | Renew`,
)
const card = panel.querySelector<HTMLElement>('[data-testid="social-card"]')!
expect(card.querySelector('[data-testid="social-card-image"]')!.className).toContain(
'aspect-191/100',
)
expect(textOf(card)).toContain('renew.example')
expect(textOf(card.querySelector('[data-testid="social-card-title"]'))).toBe(
`${ARTICLE_TITLE} | Renew`,
)
expect(card.querySelector('[data-testid="social-card-description"]')!.className).toContain(
'line-clamp-2',
)
})
})
describe('Keyboard', () => {
it('closes the panel with Escape as Cancel does, discards the typing and returns focus to Edit search and sharing', async () => {
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
await typeInto(fieldByLabel(panel, 'Meta title')!, 'Typed then discarded')
panel.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 50))
await flushPromises()
expect(panelRoot()).toBeNull()
expect(document.activeElement).toBe(document.body.querySelector('[data-testid="seo-edit"]'))
expect(patchBodies(backend)).toEqual([])
expect(document.body.textContent).not.toContain('Unsaved changes')
await openPanel()
expect(fieldByLabel(panelRoot()!, 'Meta title')!.value).toBe('')
})
it('discards the typing with Cancel without changing the status', async () => {
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
await typeInto(fieldByLabel(panel, 'Meta title')!, 'Typed then discarded')
await click('Cancel', panel)
await new Promise((resolve) => setTimeout(resolve, 50))
await flushPromises()
expect(panelRoot()).toBeNull()
expect(patchBodies(backend)).toEqual([])
expect(document.body.textContent).not.toContain('Unsaved changes')
void openSection
void counterTexts
})
})