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

@@ -7,7 +7,7 @@ import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
import { articleMeta, oneSite } from './editorMetaKit'
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
const FIXED_NOW = '2026-09-26T11:02:00Z'
@@ -116,6 +116,7 @@ function routeFor(url: string, init?: RequestInit) {
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
const routes = {
'GET /v1/site/': () => mockResponse(200, oneSite),
'POST /v1/article/12/seo/check': () => mockResponse(200, readyCheck),
...handlers,
}
return vi.fn((url: string, init?: RequestInit) => {
@@ -7,7 +7,7 @@ import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
import { articleMeta, oneSite } from './editorMetaKit'
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
import { fixture } from './mediaTestKit'
const FIXED_NOW = '2026-09-26T11:02:00Z'
@@ -71,6 +71,9 @@ async function mountEditor(body = BODY_WITH_TWO_IMAGES) {
if (url === '/v1/site/') {
return mockResponse(200, oneSite)
}
if (url === '/v1/article/12/seo/check') {
return mockResponse(200, readyCheck)
}
if (url === '/v1/media/?page=1&size=25') {
return mockResponse(200, libraryPage())
}
@@ -182,7 +185,8 @@ describe('Insert puts the chosen image in the body with its alt text', () => {
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
const methods = vi
.mocked(global.fetch)
.mock.calls.map(([, init]) => (init as RequestInit | undefined)?.method)
.mock.calls.filter(([url]) => !String(url).endsWith('/seo/check'))
.map(([, init]) => (init as RequestInit | undefined)?.method)
expect(methods.every((method) => method === undefined)).toBe(true)
})
@@ -174,7 +174,9 @@ describe('Editor section: the choice is remembered per browser', () => {
expect(summary()).toContain('No authors')
expect(
crowded.calls.filter(
(call) => call.method !== 'GET' || call.path.startsWith('/v1/author'),
(call) =>
(call.method !== 'GET' && !call.path.endsWith('/seo/check')) ||
call.path.startsWith('/v1/author'),
),
).toEqual([])
})
@@ -0,0 +1,350 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { failWith, metaKit, pending, textOf, unmountMeta } from './metaTestKit'
import { typeInto } from './directoryTestKit'
import {
ARTICLE_TITLE,
LONG_TITLE,
READY,
answerChecks,
checkAnswer,
checkBodies,
checkCalls,
fieldByLabel,
mountSeo,
openPanel,
openSection,
panelRoot,
patchBodies,
sectionBody,
seoBackend,
suggestion,
} from './seoTestKit'
import ArticleEditor from '@/components/ArticleEditor.vue'
import MediaField from '@/components/MediaField.vue'
import { useApi } from '@/composables/useApi'
import { SEO_CHECK_DEBOUNCE_MS } from '@/composables/useSeoCheck'
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 fakeTimers() {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
}
async function pause(ms = SEO_CHECK_DEBOUNCE_MS) {
await vi.advanceTimersByTimeAsync(ms)
await flushPromises()
}
async function editBody(html: string) {
metaKit.wrapper!.findComponent(ArticleEditor).vm.$emit('update:modelValue', html)
await flushPromises()
}
function checkRequests() {
const mocked = vi.mocked(useApi).mock.results.at(-1)!.value as {
request: { mock: { calls: [string, RequestInit | undefined][] } }
}
return mocked.request.mock.calls.filter(([path]) => path.endsWith('/seo/check'))
}
describe('The first check runs when the editor loads', () => {
it('sends exactly one check with an empty body once the article is loaded', async () => {
const backend = await mountSeo(seoBackend())
expect(checkBodies(backend)).toEqual([{}])
})
it('sends no check while the article is pending', async () => {
const backend = seoBackend()
const gate = pending<unknown>()
backend.responders['GET /v1/article/12'] = () => gate.promise
await mountSeo(backend)
await new Promise((resolve) => setTimeout(resolve, 200))
expect(checkCalls(backend)).toHaveLength(0)
})
it('sends no check when the article fails to load', async () => {
const backend = seoBackend()
backend.responders['GET /v1/article/12'] = failWith(500)
await mountSeo(backend)
expect(checkCalls(backend)).toHaveLength(0)
})
})
describe('What the check is sent', () => {
it('sends only the changed field', async () => {
const backend = await mountSeo(seoBackend())
await openPanel()
fakeTimers()
await typeInto(fieldByLabel(panelRoot()!, 'Meta title')!, LONG_TITLE)
await pause()
expect(checkBodies(backend).at(-1)).toEqual({ seo_title: LONG_TITLE })
})
it('includes the unsaved body edit so the suggestions reflect what is on screen', async () => {
const backend = await mountSeo(seoBackend())
fakeTimers()
await editBody('<p>Edited on screen</p>')
await pause()
expect(checkBodies(backend).at(-1)).toEqual({ body: '<p>Edited on screen</p>' })
})
})
describe('The check waits for a pause in typing', () => {
type Field = { name: string; value: string; key: string; prepare?: () => Promise<void> }
async function openAdvanced() {
const toggle = Array.from(
panelRoot()!.querySelectorAll<HTMLElement>('[data-testid="seo-disclosure-toggle"]'),
).find((button) => textOf(button).startsWith('Advanced'))!
toggle.click()
await flushPromises()
}
async function openSocial() {
const toggle = Array.from(
panelRoot()!.querySelectorAll<HTMLElement>('[data-testid="seo-disclosure-toggle"]'),
).find((button) => textOf(button).startsWith('Social sharing'))!
toggle.click()
await flushPromises()
}
async function unlockSlug() {
panelRoot()!.querySelector<HTMLElement>('button[aria-label="Edit slug"]')!.click()
await flushPromises()
}
const fields: Field[] = [
{ name: 'Meta title', value: 'A new meta title', key: 'seo_title' },
{ name: 'Meta description', value: 'A new meta description', key: 'seo_description' },
{ name: 'Excerpt', value: 'A new excerpt', key: 'excerpt' },
{ name: 'Alt text', value: 'A tiled wall', key: 'cover_image_alt' },
{ name: 'Social title', value: 'A social title', key: 'social_title', prepare: openSocial },
{
name: 'Canonical URL',
value: 'https://renew.example/costs',
key: 'canonical_url',
prepare: openAdvanced,
},
{ name: 'URL slug', value: 'bathroom-costs', key: 'slug', prepare: unlockSlug },
]
it.each(fields)('sends one check with the latest $name only after the pause', async (field) => {
const backend = await mountSeo(seoBackend({ article: { cover_image_id: 1 } }))
await openPanel()
await field.prepare?.()
fakeTimers()
const before = checkCalls(backend).length
const input = fieldByLabel(panelRoot()!, field.name)!
await typeInto(input, field.value.slice(0, 4))
await pause(SEO_CHECK_DEBOUNCE_MS - 100)
await typeInto(input, field.value)
await pause(SEO_CHECK_DEBOUNCE_MS - 100)
expect(checkCalls(backend)).toHaveLength(before)
await pause(100)
expect(checkCalls(backend)).toHaveLength(before + 1)
expect(checkBodies(backend).at(-1)).toEqual({ [field.key]: field.value })
})
it('refreshes the suggestions and the summary line after the pause', async () => {
const backend = await mountSeo(seoBackend())
await openPanel()
fakeTimers()
answerChecks(
backend,
checkAnswer([suggestion('title_long', 'seo_title', { length: 74, max: 60 })]),
)
await typeInto(fieldByLabel(panelRoot()!, 'Meta title')!, LONG_TITLE)
await pause()
expect(
textOf(panelRoot()!.querySelector('[data-testid="seo-panel-suggestions"]')),
).toContain('The meta title is 74 characters.')
expect(textOf(sectionBody())).toContain('The meta title is 74 characters.')
})
})
describe('A late answer never overwrites a newer one', () => {
it('shows the suggestions of the newer check and aborts the previous request', async () => {
const backend = await mountSeo(seoBackend())
await openPanel()
fakeTimers()
const gates: ReturnType<typeof pending<unknown>>[] = []
answerChecks(backend, () => {
const gate = pending<unknown>()
gates.push(gate)
return gate.promise
})
const title = fieldByLabel(panelRoot()!, 'Meta title')!
await typeInto(title, 'First edit')
await pause()
await typeInto(title, 'Second edit')
await pause()
expect(gates).toHaveLength(2)
const [first, second] = checkRequests().slice(-2)
expect((first![1]!.signal as AbortSignal).aborted).toBe(true)
expect((second![1]!.signal as AbortSignal).aborted).toBe(false)
gates[1]!.resolve(checkAnswer([suggestion('cover_none', 'cover_image_id')], 'suggestions'))
await flushPromises()
gates[0]!.resolve(checkAnswer([], 'hidden'))
await flushPromises()
expect(textOf(sectionBody())).toContain('This article has no cover image.')
expect(textOf(sectionBody())).not.toContain('Hidden from search engines')
expect(textOf(panelRoot()!)).toContain('This article has no cover image.')
})
})
describe('Discrete changes check immediately', () => {
it('checks without waiting when "Hide from search engines" is toggled', async () => {
const backend = await mountSeo(seoBackend())
await openPanel()
fakeTimers()
const before = checkCalls(backend).length
panelRoot()!.querySelector<HTMLElement>('button[role="switch"]')!.click()
await flushPromises()
expect(checkCalls(backend)).toHaveLength(before + 1)
expect(checkBodies(backend).at(-1)).toEqual({ noindex: true })
})
it('checks without waiting when the cover is picked and when it is removed', async () => {
const backend = await mountSeo(seoBackend())
await openPanel()
fakeTimers()
const before = checkCalls(backend).length
const cover = metaKit.wrapper!.findAllComponents(MediaField)[0]!
cover.vm.$emit('update:modelValue', 2)
await flushPromises()
expect(checkCalls(backend)).toHaveLength(before + 1)
expect(checkBodies(backend).at(-1)).toEqual({ cover_image_id: 2 })
cover.vm.$emit('update:modelValue', null)
await flushPromises()
expect(checkCalls(backend)).toHaveLength(before + 2)
expect(checkBodies(backend).at(-1)).toEqual({})
})
it('checks without waiting when a social image is picked', async () => {
const backend = await mountSeo(seoBackend())
await openPanel()
fakeTimers()
const before = checkCalls(backend).length
Array.from(
panelRoot()!.querySelectorAll<HTMLElement>('[data-testid="seo-disclosure-toggle"]'),
)
.find((button) => textOf(button).startsWith('Social sharing'))!
.click()
await flushPromises()
const social = metaKit.wrapper!.findAllComponents(MediaField)[1]!
social.vm.$emit('update:modelValue', 2)
await flushPromises()
expect(checkCalls(backend)).toHaveLength(before + 1)
expect(checkBodies(backend).at(-1)).toEqual({ social_image_id: 2 })
})
})
describe('Nothing is saved by a check', () => {
it('sends no PATCH for any check and leaves the status unchanged', async () => {
const backend = await mountSeo(seoBackend())
await openPanel()
fakeTimers()
await typeInto(fieldByLabel(panelRoot()!, 'Meta title')!, LONG_TITLE)
await pause()
panelRoot()!.querySelector<HTMLElement>('button[role="switch"]')!.click()
await flushPromises()
expect(checkCalls(backend).length).toBeGreaterThan(2)
expect(patchBodies(backend)).toEqual([])
expect(document.body.textContent).not.toContain('Unsaved changes')
})
})
describe('The server owns the suggestion rules', () => {
it('shows no title_long message for a 74-character title when the check does not answer it', async () => {
const backend = await mountSeo(seoBackend())
await openPanel()
fakeTimers()
answerChecks(backend, READY)
await typeInto(fieldByLabel(panelRoot()!, 'Meta title')!, LONG_TITLE)
await pause()
expect(LONG_TITLE).toHaveLength(74)
expect(textOf(panelRoot()!)).not.toContain('characters. Search engines may cut it off')
expect(textOf(panelRoot()!)).toContain('All good')
})
})
describe('Discarding the panel', () => {
it('aborts a pending check for the discarded values so it cannot overwrite the summary', async () => {
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
fakeTimers()
const gate = pending<unknown>()
answerChecks(backend, () => gate.promise)
await typeInto(fieldByLabel(panel, 'Meta title')!, LONG_TITLE)
await pause()
const [request] = checkRequests().slice(-1)
const cancel = Array.from(panel.querySelectorAll<HTMLButtonElement>('button')).find(
(button) => textOf(button) === 'Cancel',
)!
cancel.click()
await flushPromises()
gate.resolve(checkAnswer([suggestion('title_long', 'seo_title')]))
await flushPromises()
expect((request![1]!.signal as AbortSignal).aborted).toBe(true)
expect(textOf(sectionBody())).not.toContain('The meta title is')
})
})
describe('The panel check state', () => {
it('sends a check on open and shows Checking this article… while it is pending, with the fields usable', async () => {
const backend = await mountSeo(seoBackend())
const before = checkCalls(backend).length
const gate = pending<unknown>()
answerChecks(backend, () => gate.promise)
const panel = await openPanel()
expect(checkCalls(backend)).toHaveLength(before + 1)
expect(textOf(panel.querySelector('[data-testid="seo-panel-suggestions"]'))).toContain(
'Checking this article…',
)
const title = fieldByLabel(panel, 'Meta title')!
expect(title.disabled).toBe(false)
expect(title.placeholder).toBe(ARTICLE_TITLE)
void openSection
})
})
@@ -0,0 +1,415 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { click, failWith, networkFailure, pending, textOf, unmountMeta } from './metaTestKit'
import {
mountSeo,
openPanel,
openSection,
panelRoot,
patchBodies,
seoBackend,
seoCalls,
} 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()
vi.unstubAllGlobals()
await unmountMeta()
})
function resolvedSeo(fallbacks: Record<string, string> = {}) {
return {
title: 'How we price a bathroom renovation | Renew',
description: 'A bathroom renovation is priced by room size.',
canonical_url: 'https://renew.example/how-we-price-a-bathroom-renovation',
robots: 'index, follow',
og: {
type: 'article',
title: 'How we price a bathroom renovation | Renew',
description: 'A bathroom renovation is priced by room size.',
image: 'https://renew.example/media/north-wall.jpg',
image_alt: 'A tiled bathroom wall during renovation',
url: 'https://renew.example/how-we-price-a-bathroom-renovation',
site_name: 'Renew',
locale: 'en',
},
twitter: {
card: 'summary_large_image',
title: 'How we price a bathroom renovation | Renew',
description: 'A bathroom renovation is priced by room size.',
image: 'https://renew.example/media/north-wall.jpg',
image_alt: 'A tiled bathroom wall during renovation',
},
image: {
url: 'https://renew.example/media/north-wall.jpg',
alt: 'A tiled bathroom wall during renovation',
source: 'cover_image',
},
authors: [
{
name: 'Inès Duarte',
url: 'https://ines-duarte.example',
same_as: ['https://www.linkedin.com/in/ines-duarte'],
},
],
published_at: '2026-03-12T09:14:00Z',
modified_at: '2026-10-15T11:02:00Z',
fallbacks: {
title: 'title',
description: 'body',
image: 'cover',
canonical: 'site',
...fallbacks,
},
json_ld: {
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'How we price a bathroom renovation',
datePublished: '2026-03-12T09:14:00Z',
},
}
}
async function mountWithDialog(options: { online?: boolean; seo?: unknown; width?: number } = {}) {
const backend = seoBackend()
backend.article.is_online = options.online ?? true
backend.responders['GET /v1/article/12/seo'] = () => options.seo ?? resolvedSeo()
await mountSeo(backend, options.width ? { width: options.width } : {})
return backend
}
async function openDialog() {
await openSection()
const opener = document.body.querySelector<HTMLElement>('[data-testid="seo-preview"]')!
opener.focus()
opener.click()
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 0))
await flushPromises()
return document.body
.querySelector<HTMLElement>('[data-testid="api-preview"]')!
.closest('[role="dialog"]') as HTMLElement
}
async function selectTab(label: string) {
const tab = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).find(
(candidate) => textOf(candidate) === label,
)!
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
tab.click()
await flushPromises()
}
function stubClipboard(writeText: ReturnType<typeof vi.fn> | undefined) {
vi.stubGlobal('navigator', { ...navigator, clipboard: writeText ? { writeText } : undefined })
}
describe('Opening from the section and from the panel', () => {
it('requests GET /v1/article/12/seo once on each opening, from both openers', async () => {
const backend = await mountWithDialog()
await openDialog()
expect(seoCalls(backend)).toHaveLength(1)
await click('Close')
await new Promise((resolve) => setTimeout(resolve, 50))
await flushPromises()
await openDialog()
expect(seoCalls(backend)).toHaveLength(2)
await click('Close')
await new Promise((resolve) => setTimeout(resolve, 50))
await flushPromises()
const panel = await openPanel()
await click('What the frontend gets', panel)
await flushPromises()
expect(seoCalls(backend)).toHaveLength(3)
})
it('opens a 720 px dialog on desktop', async () => {
await mountWithDialog()
const dialog = await openDialog()
expect(dialog.className).toContain('max-w-180')
})
it('stacks above the Details sheet it can be opened from', async () => {
await mountWithDialog({ 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-preview"]')!.click()
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 0))
const dialog = document
.querySelector('[data-testid="api-preview"]')!
.closest('[role="dialog"]') as HTMLElement
expect(dialog.className).toContain('z-60')
expect(document.body.querySelector('[data-slot="overlay"].z-60')).not.toBeNull()
})
it('opens a bottom sheet on mobile', async () => {
await mountWithDialog({ 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-preview"]')!.click()
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 0))
await flushPromises()
const dialog = document
.querySelector('[data-testid="api-preview"]')!
.closest('[role="dialog"]') as HTMLElement
expect(dialog.className).toContain('max-sm:!bottom-0')
})
})
describe('Formatted', () => {
it('shows the title, the description, the tabs and the buttons', async () => {
await mountWithDialog()
const dialog = await openDialog()
const text = textOf(dialog)
expect(text).toContain('What the frontend gets')
expect(text).toContain(
"The SEO data returned by GET /v1/article/12/seo, read only. RenewCMS doesn't render pages or add meta tags: your frontend does.",
)
expect(text).toContain('Formatted')
expect(text).toContain('Raw JSON')
expect(text).toContain('Copy JSON')
expect(text).toContain('Close')
})
it('lists the keys as a compact key and value list', async () => {
await mountWithDialog()
const dialog = await openDialog()
const keys = Array.from(dialog.querySelectorAll('dt')).map((node) => textOf(node))
expect(keys).toEqual([
'title',
'description',
'canonical_url',
'robots',
'og.type',
'og.image',
'og.image_alt',
'twitter.card',
'authors',
'published_at',
'modified_at',
'json_ld',
])
const formatted = textOf(dialog.querySelector('[data-testid="api-preview-formatted"]'))
expect(formatted).toContain('How we price a bathroom renovation | Renew')
expect(formatted).toContain('Inès Duarte · ines-duarte.example · 1 social link')
expect(formatted).toContain('12 Mar 2026, 09:14')
expect(formatted).toContain('Article · headline, datePublished')
})
it.each([
{ key: 'title', source: 'title', chip: 'Using article title' },
{ key: 'description', source: 'body', chip: 'Using first sentence of the body' },
{ key: 'description', source: 'excerpt', chip: 'Using the excerpt' },
{ key: 'description', source: 'site', chip: 'Using the site default description' },
{ key: 'image', source: 'cover', chip: 'Using cover image' },
{ key: 'image', source: 'site', chip: 'Using site social image' },
{ key: 'canonical', source: 'site', chip: 'Using site address and slug' },
{ key: 'image', source: 'none', chip: 'No image' },
])('shows "$chip" when fallbacks.$key is $source', async ({ key, source, chip }) => {
await mountWithDialog({
seo: resolvedSeo({
title: 'seo_title',
description: 'seo_description',
image: 'social',
canonical: 'canonical_url',
[key]: source,
}),
})
const dialog = await openDialog()
const chips = Array.from(dialog.querySelectorAll('[data-testid="fallback-chip"]')).map(
(node) => textOf(node),
)
expect(chips).toEqual([chip])
})
it('shows no chip for a value that is not a fallback', async () => {
await mountWithDialog({
seo: resolvedSeo({
title: 'seo_title',
description: 'seo_description',
image: 'social',
canonical: 'canonical_url',
}),
})
const dialog = await openDialog()
expect(dialog.querySelectorAll('[data-testid="fallback-chip"]')).toHaveLength(0)
})
})
describe('Raw JSON', () => {
it('shows the response exactly as the API returned it, pretty printed in the mono face', async () => {
const seo = resolvedSeo()
await mountWithDialog({ seo })
const dialog = await openDialog()
await selectTab('Raw JSON')
const raw = dialog.querySelector<HTMLElement>('[data-testid="api-preview-raw"]')!
expect(raw.textContent).toBe(JSON.stringify(seo, null, 2))
expect(raw.className).toContain('font-mono')
})
})
describe('Copy JSON', () => {
it('writes the pretty printed raw object, reads Copied for 2 seconds, announces it, then reads Copy JSON again', async () => {
const seo = resolvedSeo()
await mountWithDialog({ seo })
const dialog = await openDialog()
const writeText = vi.fn().mockResolvedValue(undefined)
stubClipboard(writeText)
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await click('Copy JSON', dialog)
expect(writeText).toHaveBeenCalledWith(JSON.stringify(seo, null, 2))
expect(textOf(dialog.querySelector('[data-testid="api-preview-copy"]'))).toBe('Copied')
expect(textOf(dialog.querySelector('[role="status"]'))).toBe('Copied')
await vi.advanceTimersByTimeAsync(1900)
expect(textOf(dialog.querySelector('[data-testid="api-preview-copy"]'))).toBe('Copied')
await vi.advanceTimersByTimeAsync(200)
await flushPromises()
expect(textOf(dialog.querySelector('[data-testid="api-preview-copy"]'))).toBe('Copy JSON')
expect(textOf(dialog.querySelector('[role="status"]'))).toBe('')
})
it.each([
['rejects', vi.fn().mockRejectedValue(new Error('denied'))],
['is unavailable', undefined],
])('keeps reading Copy JSON when the clipboard %s', async (_name, writeText) => {
await mountWithDialog()
const dialog = await openDialog()
stubClipboard(writeText)
await click('Copy JSON', dialog)
expect(textOf(dialog.querySelector('[data-testid="api-preview-copy"]'))).toBe('Copy JSON')
expect(textOf(dialog.querySelector('[role="status"]'))).toBe('')
})
})
describe('Draft article', () => {
it('shows the draft note above the data, and the data', async () => {
await mountWithDialog({ online: false })
const dialog = await openDialog()
const note = dialog.querySelector('[data-testid="api-preview-draft"]')!
expect(textOf(note)).toContain(
'This article is a draft. The API returns this data only to signed-in callers until it is published.',
)
expect(textOf(dialog.querySelector('[data-testid="api-preview-formatted"]'))).toContain(
'robots',
)
})
it('shows no draft note for an online article', async () => {
await mountWithDialog({ online: true })
const dialog = await openDialog()
expect(dialog.querySelector('[data-testid="api-preview-draft"]')).toBeNull()
})
})
describe('Loading', () => {
it('shows a loading state and disables Copy JSON while the request is pending', async () => {
const backend = await mountWithDialog()
const gate = pending<unknown>()
backend.responders['GET /v1/article/12/seo'] = () => gate.promise
const dialog = await openDialog()
expect(dialog.querySelector('[data-testid="api-preview-loading"]')).not.toBeNull()
expect(
dialog.querySelector<HTMLButtonElement>('[data-testid="api-preview-copy"]')!.disabled,
).toBe(true)
})
})
describe('Error with retry', () => {
it.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])(
'shows the error and requests the data again on Try again after %s',
async (_name, failure) => {
const backend = await mountWithDialog()
backend.responders['GET /v1/article/12/seo'] = failure
const dialog = await openDialog()
const text = textOf(dialog)
expect(text).toContain("Couldn't load the SEO data")
expect(text).toContain(
'GET /v1/article/12/seo failed. Your article is unchanged. Try again in a moment.',
)
expect(document.body.textContent).not.toContain('Unsaved changes')
expect(patchBodies(backend)).toEqual([])
const before = seoCalls(backend).length
backend.responders['GET /v1/article/12/seo'] = () => resolvedSeo()
await click('Try again', dialog)
expect(seoCalls(backend)).toHaveLength(before + 1)
expect(dialog.querySelector('[data-testid="api-preview-formatted"]')).not.toBeNull()
},
)
})
describe('The dialog is read only', () => {
it('sends no PATCH and no POST, and Close returns focus to its opener', async () => {
const backend = await mountWithDialog()
const callsBefore = backend.calls.length
const dialog = await openDialog()
await click('Close', dialog)
await new Promise((resolve) => setTimeout(resolve, 50))
await flushPromises()
expect(backend.calls.slice(callsBefore).filter((call) => call.method !== 'GET')).toEqual([])
expect(document.body.querySelector('[data-testid="api-preview"]')).toBeNull()
expect(document.activeElement).toBe(
document.body.querySelector('[data-testid="seo-preview"]'),
)
void panelRoot
})
it('closes with Escape and returns focus to its opener', async () => {
await mountWithDialog()
const dialog = await openDialog()
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 50))
await flushPromises()
expect(document.body.querySelector('[data-testid="api-preview"]')).toBeNull()
expect(document.activeElement).toBe(
document.body.querySelector('[data-testid="seo-preview"]'),
)
})
})
@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { initializeLocale, setLocale } from '@/i18n'
import { click, textOf, unmountMeta } from './metaTestKit'
import {
FOUR_SUGGESTIONS,
answerChecks,
checkAnswer,
mountSeo,
openPanel,
openSection,
panelRoot,
seoBackend,
} 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()
setLocale('fr')
})
afterEach(async () => {
await unmountMeta()
localStorage.clear()
initializeLocale()
})
describe('French', () => {
it('walks through the section, the panel and the dialog in French', async () => {
const backend = seoBackend()
answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS))
backend.responders['GET /v1/article/12/seo'] = () => ({
title: 'T',
description: 'D',
canonical_url: 'https://renew.example/x',
robots: 'index, follow',
og: { type: 'article', image: '', image_alt: '' },
twitter: { card: 'summary' },
image: { url: '', alt: '', source: 'none' },
authors: [],
published_at: null,
modified_at: '2026-09-26T11:02:00Z',
fallbacks: { title: 'title', description: 'body', image: 'none', canonical: 'site' },
json_ld: { '@type': 'Article' },
})
await mountSeo(backend)
expect(document.body.querySelector('[data-testid="seo-summary"]')!.textContent).toBe(
'4 suggestions',
)
const body = await openSection()
const section = textOf(body)
expect(section).toContain('Adresse')
expect(section).toContain(
'Le titre meta fait 74 caractères. Les moteurs de recherche peuvent le couper après environ 60.',
)
expect(section).toContain('Aller au titre meta')
expect(section).toContain('+1 de plus dans Recherche et partage')
expect(section).toContain('Modifier la recherche et le partage')
const panel = await openPanel()
const panelContent = textOf(panel)
expect(panelContent).toContain('Recherche et partage')
expect(panelContent).toContain('Slug de l’URL')
expect(panelContent).toContain('Titre de l’article utilisé')
expect(panelContent).toContain('Partage social')
expect(panelContent).toContain('Avancé')
expect(panelContent).toContain('Masquer des moteurs de recherche')
expect(panelContent).toContain('Résultat de recherche')
expect(panelContent).toContain('Carte sociale')
expect(panelContent).toContain('Enregistrer les modifications')
await click('Annuler', panel)
await new Promise((resolve) => setTimeout(resolve, 50))
await flushPromises()
expect(panelRoot()).toBeNull()
await click('Ce que reçoit le frontend')
await flushPromises()
const dialog = textOf(document.body.querySelector('[data-testid="api-preview"]'))
expect(dialog).toContain('Formaté')
expect(dialog).toContain('JSON brut')
expect(dialog).toContain('Copier le JSON')
})
})
@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { config, flushPromises } from '@vue/test-utils'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
import { SEO_SECTION_OPEN_KEY } from '@/lib/seoSectionMemory'
import { click, unmountMeta } from './metaTestKit'
import {
FOUR_SUGGESTIONS,
answerChecks,
checkAnswer,
mountSeo,
openPanel,
openSection,
section,
sectionBody,
sectionToggle,
seoBackend,
} 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 () => {
config.global.config.errorHandler = undefined
vi.restoreAllMocks()
await unmountMeta()
})
function securityError() {
return new DOMException('Access denied', 'SecurityError')
}
function collectVueErrors() {
const uncaught: unknown[] = []
const previous = config.global.config.errorHandler
config.global.config.errorHandler = (error) => {
uncaught.push(error)
}
return {
uncaught,
stop: () => {
config.global.config.errorHandler = previous
},
}
}
describe('The choice is remembered per browser, with its own key', () => {
it('uses a key of its own, independent of the Site and authors key', () => {
expect(SEO_SECTION_OPEN_KEY).not.toBe(META_SECTION_OPEN_KEY)
})
it('keeps an opening for the next article and a collapse after a reload, sending nothing to the API', async () => {
const backend = seoBackend()
await mountSeo(backend)
const callsBefore = backend.calls.length
await openSection()
expect(localStorage.getItem(SEO_SECTION_OPEN_KEY)).toBe('true')
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBeNull()
expect(backend.calls).toHaveLength(callsBefore)
await unmountMeta()
await mountSeo(seoBackend())
expect(sectionBody()).not.toBeNull()
sectionToggle().click()
await flushPromises()
expect(localStorage.getItem(SEO_SECTION_OPEN_KEY)).toBe('false')
await unmountMeta()
await mountSeo(seoBackend())
expect(sectionBody()).toBeNull()
})
it('shares the choice between the sidebar and the Details sheet', 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()
expect(localStorage.getItem(SEO_SECTION_OPEN_KEY)).toBe('true')
const sidebar = document.body.querySelector('[data-testid="article-details"]')!
expect(sidebar.querySelector('[data-testid="seo-body"]')).not.toBeNull()
})
it('does not change the stored choice when the panel or the dialog is opened from the section', async () => {
const backend = seoBackend()
answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS))
await mountSeo(backend)
await openSection()
const writes = vi.spyOn(Storage.prototype, 'setItem')
const panel = await openPanel()
expect(panel).not.toBeNull()
expect(writes).not.toHaveBeenCalled()
expect(localStorage.getItem(SEO_SECTION_OPEN_KEY)).toBe('true')
expect(section()).not.toBeNull()
})
})
describe('A storage that throws never breaks the editor', () => {
it.each(['getItem', 'setItem'] as const)(
'renders the editor and toggles the section for this page view when localStorage.%s throws',
async (call) => {
const errors = collectVueErrors()
vi.spyOn(Storage.prototype, call).mockImplementation(() => {
throw securityError()
})
await mountSeo(seoBackend())
expect(section()).not.toBeNull()
expect(sectionBody()).toBeNull()
sectionToggle().click()
await flushPromises()
expect(sectionBody()).not.toBeNull()
sectionToggle().click()
await flushPromises()
expect(sectionBody()).toBeNull()
errors.stop()
expect(errors.uncaught).toEqual([])
},
)
it('falls back to collapsed when reading throws, even if a choice was stored', async () => {
localStorage.setItem(SEO_SECTION_OPEN_KEY, 'true')
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw securityError()
})
await mountSeo(seoBackend())
expect(sectionBody()).toBeNull()
})
it('silently skips the write when setItem throws', async () => {
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw securityError()
})
await mountSeo(seoBackend())
await openSection()
expect(setItem).toHaveBeenCalled()
expect(localStorage.getItem(SEO_SECTION_OPEN_KEY)).toBeNull()
})
})
@@ -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
})
})
@@ -0,0 +1,446 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import {
click,
failWith,
metaKit,
networkFailure,
pending,
textOf,
unmountMeta,
} from './metaTestKit'
import { typeInto } from './directoryTestKit'
import {
FOUR_SUGGESTIONS,
answerChecks,
checkAnswer,
checkBodies,
checkCalls,
fieldByLabel,
mountSeo,
openDisclosure,
openPanel,
panelRoot,
patchBodies,
saveChangesButton,
seoBackend,
} from './seoTestKit'
import ArticleEditor from '@/components/ArticleEditor.vue'
import router from '@/router'
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.restoreAllMocks()
await unmountMeta()
})
const NOTICE =
"Couldn't save search and sharing. Your changes are still here. Try again in a moment."
function toasts() {
return textOf(document.body.querySelector('[data-slot="viewport"]'))
}
function saveButton() {
return document.body.querySelector<HTMLButtonElement>('[data-testid="save-button"]')!
}
async function changeTitle(panel: HTMLElement, value = 'A new meta title') {
await typeInto(fieldByLabel(panel, 'Meta title')!, value)
}
async function submit(panel: HTMLElement) {
saveChangesButton(panel).click()
await flushPromises()
}
async function editBody(html: string) {
metaKit.wrapper!.findComponent(ArticleEditor).vm.$emit('update:modelValue', html)
await flushPromises()
}
async function failedPanelSave(failure = failWith(500)) {
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
await changeTitle(panel)
backend.responders['PATCH /v1/article/12'] = failure
await submit(panel)
return { backend, panel }
}
describe('Save changes commits the panel to the draft and saves', () => {
it('sends exactly one PATCH holding seo_title only', async () => {
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
await changeTitle(panel)
await submit(panel)
expect(patchBodies(backend)).toEqual([{ seo_title: 'A new meta title' }])
})
it('sends the fields that changed and no unchanged one', async () => {
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
await openDisclosure('Social sharing')
await openDisclosure('Advanced')
await typeInto(fieldByLabel(panel, 'Excerpt')!, 'A short summary')
await typeInto(fieldByLabel(panel, 'Social title')!, 'Shared title')
await typeInto(fieldByLabel(panel, 'Canonical URL')!, 'https://renew.example/costs')
panel.querySelector<HTMLElement>('button[role="switch"]')!.click()
await flushPromises()
await submit(panel)
expect(patchBodies(backend)).toEqual([
{
excerpt: 'A short summary',
social_title: 'Shared title',
canonical_url: 'https://renew.example/costs',
noindex: true,
},
])
})
it('sends the pending body edit in the same PATCH', async () => {
const backend = await mountSeo(seoBackend())
await editBody('<p>Edited on screen</p>')
const panel = await openPanel()
await changeTitle(panel)
await submit(panel)
expect(patchBodies(backend)).toEqual([
{ seo_title: 'A new meta title', body: '<p>Edited on screen</p>' },
])
})
it('closes without a request when nothing changed', async () => {
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
await submit(panel)
expect(patchBodies(backend)).toEqual([])
expect(panelRoot()).toBeNull()
})
})
describe('Saving state', () => {
it('disables every field of the panel and reads Saving… while the request runs', async () => {
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
await changeTitle(panel)
const gate = pending<unknown>()
backend.responders['PATCH /v1/article/12'] = () => gate.promise
await submit(panel)
expect(fieldByLabel(panel, 'Meta title')!.disabled).toBe(true)
expect(fieldByLabel(panel, 'Meta description')!.disabled).toBe(true)
expect(fieldByLabel(panel, 'Excerpt')!.disabled).toBe(true)
expect(textOf(panel)).toContain('Saving…')
expect(
textOf(document.body.querySelector('[data-testid="save-button"]')!.parentElement),
).toContain('Save')
expect(document.body.textContent).toContain('Saving…')
gate.resolve({})
await flushPromises()
})
})
describe('Saved', () => {
it('closes the panel, reads Saved, refreshes the check and toasts', async () => {
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
await changeTitle(panel)
const before = checkCalls(backend).length
await submit(panel)
await new Promise((resolve) => setTimeout(resolve, 50))
await flushPromises()
expect(panelRoot()).toBeNull()
expect(document.body.textContent).toContain('Saved')
expect(document.body.textContent).not.toContain('Unsaved changes')
expect(checkCalls(backend)).toHaveLength(before + 1)
expect(checkBodies(backend).at(-1)).toEqual({})
expect(toasts()).toContain('Search and sharing saved')
expect(toasts()).toContain('Your search and sharing settings are up to date.')
})
})
describe('Saved refreshes the check', () => {
it('shows the answer of the new check, not the cached one, after the panel closes', async () => {
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
await changeTitle(panel)
answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS))
await submit(panel)
await new Promise((resolve) => setTimeout(resolve, 50))
await flushPromises()
expect(panelRoot()).toBeNull()
expect(textOf(document.body.querySelector('[data-testid="seo-body"]'))).toContain(
'The meta title is 74 characters.',
)
})
})
describe('Save failed', () => {
it.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('keeps the panel open with the notice and the values on %s', async (_name, failure) => {
const { panel } = await failedPanelSave(failure)
expect(panelRoot()).not.toBeNull()
expect(textOf(panel)).toContain(NOTICE)
expect(fieldByLabel(panel, 'Meta title')!.value).toBe('A new meta title')
expect(fieldByLabel(panel, 'Meta title')!.disabled).toBe(false)
expect(saveChangesButton(panel).disabled).toBe(false)
expect(document.body.textContent).toContain('Save failed')
expect(document.body.textContent).toContain('Retry')
})
})
describe('The editor Save button treats SEO edits like any other edit', () => {
async function closeAfterFailure() {
const { backend, panel } = await failedPanelSave()
await click('Cancel', panel)
await new Promise((resolve) => setTimeout(resolve, 50))
await flushPromises()
backend.responders['PATCH /v1/article/12'] = () => backend.article
backend.responders['PATCH /v1/article/12/publish'] = () => ({
...backend.article,
is_online: true,
})
return backend
}
it('reads Unsaved changes, enables Save and offers Save and publish', async () => {
await closeAfterFailure()
expect(document.body.textContent).toContain('Unsaved changes')
expect(saveButton().disabled).toBe(false)
expect(document.body.textContent).toContain('Save and publish')
})
it('sends seo_title with the Save button', async () => {
const backend = await closeAfterFailure()
saveButton().click()
await flushPromises()
expect(patchBodies(backend).at(-1)).toEqual({ seo_title: 'A new meta title' })
})
it('sends seo_title with Ctrl+S', async () => {
const backend = await closeAfterFailure()
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true }))
await flushPromises()
expect(patchBodies(backend).at(-1)).toEqual({ seo_title: 'A new meta title' })
})
it('sends seo_title with Cmd+S', async () => {
const backend = await closeAfterFailure()
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', metaKey: true }))
await flushPromises()
expect(patchBodies(backend).at(-1)).toEqual({ seo_title: 'A new meta title' })
})
it('saves with Save and publish and then sends the publish request', async () => {
const backend = await closeAfterFailure()
const before = backend.calls.length
await click('Save and publish')
const after = backend.calls.slice(before).filter((call) => call.method === 'PATCH')
expect(after.map((call) => call.path)).toEqual(['/v1/article/12', '/v1/article/12/publish'])
expect(after[0]!.body).toEqual({ seo_title: 'A new meta title' })
})
it('does not publish when the save in front of it fails', async () => {
const backend = await closeAfterFailure()
backend.responders['PATCH /v1/article/12'] = failWith(500)
await click('Save and publish')
expect(backend.calls.filter((call) => call.path === '/v1/article/12/publish')).toHaveLength(
0,
)
})
})
describe('Leaving with unsaved SEO edits', () => {
it('asks Leave without saving? when the draft holds an unsaved SEO value', async () => {
const { panel } = await failedPanelSave()
await click('Cancel', panel)
await new Promise((resolve) => setTimeout(resolve, 50))
const leaving = router.push('/article')
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 50))
await flushPromises()
expect(document.body.querySelector('[role="alertdialog"]')?.textContent).toContain(
'Leave without saving?',
)
await click('Keep editing')
await leaving
})
})
describe('A storage that throws never loses the editor', () => {
it('saves, and asks before leaving, with the values in memory when sessionStorage throws', async () => {
;['getItem', 'setItem', 'removeItem'].forEach((method) =>
vi.spyOn(Storage.prototype, method as 'getItem').mockImplementation(function (
this: Storage,
) {
if (this === sessionStorage) {
throw new DOMException('blocked', 'SecurityError')
}
return null
}),
)
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
await changeTitle(panel)
backend.responders['PATCH /v1/article/12'] = failWith(500)
await submit(panel)
await click('Cancel', panel)
await new Promise((resolve) => setTimeout(resolve, 50))
await flushPromises()
const leaving = router.push('/article')
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 50))
await flushPromises()
expect(document.body.querySelector('[role="alertdialog"]')?.textContent).toContain(
'Leave without saving?',
)
await click('Keep editing')
await leaving
backend.responders['PATCH /v1/article/12'] = () => backend.article
saveButton().click()
await flushPromises()
expect(patchBodies(backend).at(-1)).toEqual({ seo_title: 'A new meta title' })
})
})
describe('Suggestions never block', () => {
it.each([
['suggestions', checkAnswer(FOUR_SUGGESTIONS)],
['hidden', checkAnswer(FOUR_SUGGESTIONS, 'hidden')],
])(
'keeps Save, Save and publish and Ctrl+S working when the check says %s',
async (_name, answer) => {
const backend = seoBackend()
answerChecks(backend, answer)
await mountSeo(backend)
await editBody('<p>Edited</p>')
expect(saveButton().disabled).toBe(false)
expect(document.body.textContent).toContain('Save and publish')
const publish = Array.from(
document.body.querySelectorAll<HTMLButtonElement>('button'),
).find((button) => textOf(button) === 'Save and publish')!
expect(publish.disabled).toBe(false)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true }))
await flushPromises()
expect(patchBodies(backend)).toEqual([{ body: '<p>Edited</p>' }])
},
)
})
describe('Cancel', () => {
it('discards the typing without a request and without changing the status', async () => {
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
await changeTitle(panel)
const calls = backend.calls.length
await click('Cancel', panel)
await new Promise((resolve) => setTimeout(resolve, 50))
await flushPromises()
expect(panelRoot()).toBeNull()
expect(document.body.textContent).not.toContain('Unsaved changes')
expect(backend.calls.slice(calls).filter((call) => call.method !== 'GET')).toEqual([])
})
})
describe('Offline', () => {
it('shows the failed check, fails the dialog and lets the writing continue', async () => {
const backend = seoBackend()
backend.responders['POST /v1/article/12/seo/check'] = networkFailure
backend.responders['GET /v1/article/12/seo'] = networkFailure
await mountSeo(backend)
document.body.querySelector<HTMLElement>('[data-testid="seo-toggle"]')!.click()
await flushPromises()
expect(textOf(document.body.querySelector('[data-testid="seo-body"]'))).toContain(
"Couldn't check this article.",
)
await click('What the frontend gets')
await flushPromises()
expect(document.body.textContent).toContain("Couldn't load the SEO data")
await editBody('<p>Still writing</p>')
expect(document.body.textContent).toContain('Unsaved changes')
})
})
describe('Roles', () => {
it.each(['owner', 'admin', 'verified'] as const)(
'makes the section, the panel and the dialog available to a %s',
async (role) => {
const backend = seoBackend({ role })
backend.responders['GET /v1/article/12/seo'] = () => ({
title: 'T',
description: 'D',
canonical_url: 'https://renew.example/x',
robots: 'index, follow',
og: { type: 'article', image: '', image_alt: '' },
twitter: { card: 'summary' },
image: { url: '', alt: '', source: 'none' },
authors: [],
published_at: null,
modified_at: '2026-09-26T11:02:00Z',
fallbacks: {
title: 'title',
description: 'body',
image: 'none',
canonical: 'site',
},
json_ld: { '@type': 'Article' },
})
await mountSeo(backend)
const panel = await openPanel()
expect(textOf(panel)).toContain('Search and sharing')
await click('Cancel', panel)
await new Promise((resolve) => setTimeout(resolve, 50))
await flushPromises()
await click('What the frontend gets')
await flushPromises()
expect(document.body.querySelector('[data-testid="api-preview"]')).not.toBeNull()
},
)
})
@@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { click, failWith, metaSection, pending, textOf, unmountMeta } from './metaTestKit'
import {
answerChecks,
checkAnswer,
checkCalls,
mountSeo,
section,
sectionSummary,
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(unmountMeta)
describe('Position, in the sidebar and in the Details sheet', () => {
it('shows a collapsed section directly under Site and authors in the details sidebar', async () => {
await mountSeo(seoBackend())
const details = document.body.querySelector('[data-testid="article-details"]')!
const seo = section()!
expect(details.contains(seo)).toBe(true)
expect(textOf(seo)).toContain('Search and sharing')
expect(seo.previousElementSibling).toBe(metaSection())
expect(seo.querySelector('[data-testid="seo-toggle"]')!.getAttribute('aria-expanded')).toBe(
'false',
)
expect(seo.querySelector('[data-testid="seo-body"]')).toBeNull()
})
it('keeps the section out of sight on mobile until the Details sheet is opened, under Site and authors', async () => {
await mountSeo(seoBackend(), { width: MOBILE_WIDTH })
expect(document.body.querySelector('[data-testid="article-details"]')!.className).toContain(
'hidden',
)
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
await click('Details')
const sheet = document.body.querySelector<HTMLElement>('[role="dialog"]')!
const inSheet = section(sheet)!
expect(inSheet.previousElementSibling).toBe(
sheet.querySelector('[data-testid="meta-section"]'),
)
expect(sectionSummary(inSheet)?.textContent).toBe('Ready')
})
})
describe('The one-line summary follows the check', () => {
it.each([
{ name: 'pending', check: null, summary: 'Checking…', warning: false },
{ name: 'ready', check: checkAnswer([]), summary: 'Ready', warning: false },
{
name: 'one suggestion',
check: checkAnswer([suggestion('cover_none', 'cover_image_id')]),
summary: '1 suggestion',
warning: true,
},
{
name: 'two suggestions',
check: checkAnswer([
suggestion('cover_none', 'cover_image_id'),
suggestion('no_au', 'author_ids'),
]),
summary: '2 suggestions',
warning: true,
},
{
name: 'hidden with two suggestions',
check: checkAnswer(
[suggestion('cover_none', 'cover_image_id'), suggestion('no_au', 'author_ids')],
'hidden',
),
summary: 'Hidden from search',
warning: false,
},
])('reads "$summary" for a $name check', async ({ check, summary, warning }) => {
const backend = seoBackend()
const gate = pending<unknown>()
answerChecks(backend, () => (check ? check : gate.promise))
await mountSeo(backend)
const line = sectionSummary()!
expect(line.textContent).toBe(summary)
expect(line.className).toContain(warning ? 'text-warning' : 'text-muted')
expect(line.className).not.toContain(warning ? 'text-muted' : 'text-warning')
})
it("reads Couldn't check this article. rather than Ready when the check fails", async () => {
const backend = seoBackend()
backend.responders['POST /v1/article/12/seo/check'] = failWith(500)
await mountSeo(backend)
expect(sectionSummary()!.textContent).toBe("Couldn't check this article.")
})
})
describe('The existing Editor states show the section', () => {
it('shows the collapsed section with Ready in the loaded editor', async () => {
await mountSeo(seoBackend())
expect(sectionSummary()!.textContent).toBe('Ready')
})
it('shows the section header with a skeleton summary while the article loads', async () => {
const backend = seoBackend()
const gate = pending<unknown>()
backend.responders['GET /v1/article/12'] = () => gate.promise
await mountSeo(backend)
await new Promise((resolve) => setTimeout(resolve, 200))
await flushPromises()
const skeleton = document.body.querySelector('[data-testid="skeleton-details"]')!
expect(textOf(skeleton)).toContain('Search and sharing')
expect(skeleton.querySelector('[data-testid="skeleton-seo"]')).not.toBeNull()
expect(section()).toBeNull()
expect(checkCalls(backend)).toHaveLength(0)
})
it('shows no section in the Not found state', async () => {
const backend = seoBackend()
backend.responders['GET /v1/article/12'] = failWith(404)
await mountSeo(backend)
expect(section()).toBeNull()
expect(checkCalls(backend)).toHaveLength(0)
})
})
@@ -0,0 +1,145 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import RootComponent from '@/App.vue'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { typeInto } from './directoryTestKit'
import { oneSite } from './editorMetaKit'
import { fieldByLabel, openPanel, panelRoot, saveChangesButton } from './seoTestKit'
function reply(status: number, body: unknown = null, text = '') {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
text: async () => text,
} as unknown as Response
}
const article = {
id: 12,
title: 'How we price a bathroom renovation',
body: '<p>Intro</p>',
is_online: false,
site_id: 1,
authors: [{ id: 2, name: 'Inès Duarte', slug: 'ines-duarte' }],
slug: 'how-we-price-a-bathroom-renovation',
excerpt: '',
seo_title: '',
seo_description: '',
cover_image_id: null,
cover_image_alt: '',
social_title: '',
social_description: '',
social_image_id: null,
canonical_url: '',
noindex: false,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-09-26T11:02:00Z',
}
const ready = { status: 'ready', suggestions: [], slug: { available: true, suggestion: null } }
type Handler = () => Response | Promise<Response>
let wrapper: ReturnType<typeof mount> | null = null
function stubFetch(handlers: Record<string, Handler>) {
const routes: Record<string, Handler> = {
'GET /v1/site/': () => reply(200, oneSite),
'GET /v1/article/12?format=html': () => reply(200, article),
'POST /v1/article/12/seo/check': () => reply(200, ready),
'GET /v1/media/?page=1&size=100&sort=newest': () => reply(200, { items: [] }),
...handlers,
}
global.fetch = vi.fn((url: string, init?: RequestInit) => {
const handler = routes[`${init?.method ?? 'GET'} ${url}`]
if (!handler) {
throw new Error(`Unexpected fetch call: ${init?.method ?? 'GET'} ${url}`)
}
return Promise.resolve(handler())
}) as unknown as typeof fetch
}
async function mountEditor() {
setActivePinia(createPinia())
setViewportWidth(DESKTOP_WIDTH)
Element.prototype.scrollIntoView = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
await router.push('/article/12/edit')
wrapper = mount(RootComponent, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
}
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
})
afterEach(async () => {
wrapper?.unmount()
wrapper = null
await router.push('/login')
document.body.innerHTML = ''
})
describe('A session problem during the check follows the existing rules', () => {
it('lands on the login page with the redirect back to the editor when the check answers 401', async () => {
stubFetch({ 'POST /v1/article/12/seo/check': () => reply(401) })
await mountEditor()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query).toMatchObject({
redirect: '/article/12/edit',
reason: 'expired',
})
})
it('shows the Not found editor state when the check answers 404', async () => {
stubFetch({ 'POST /v1/article/12/seo/check': () => reply(404) })
await mountEditor()
expect(document.body.textContent).toContain("This article doesn't exist.")
expect(document.body.querySelector('[data-testid="seo-section"]')).toBeNull()
})
})
describe('Session expiry keeps the SEO draft', () => {
it('mirrors the draft to sessionStorage on 401 and restores it, with the panel fields, after logging in', async () => {
stubFetch({ 'PATCH /v1/article/12?format=html': () => reply(401) })
await mountEditor()
const panel = await openPanel()
await typeInto(fieldByLabel(panel, 'Meta title')!, 'A title kept across the login')
saveChangesButton(panel).click()
await flushPromises()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query.redirect).toBe('/article/12/edit')
const mirrored = JSON.parse(sessionStorage.getItem('renewcms:article-draft-seo:12')!)
expect(mirrored.seo_title).toBe('A title kept across the login')
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
stubFetch({})
await mountEditor()
expect(document.body.textContent).toContain('Unsaved changes')
const restored = await openPanel()
expect(fieldByLabel(restored, 'Meta title')!.value).toBe('A title kept across the login')
expect(panelRoot()).not.toBeNull()
})
})
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { click, failWith, textOf, unmountMeta } from './metaTestKit'
import { blur, typeInto } from './directoryTestKit'
import {
checkAnswer,
checkBodies,
checkCalls,
answerChecks,
fieldByLabel,
mountSeo,
openPanel,
panelRoot,
patchBodies,
saveChangesButton,
seoBackend,
} 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(unmountMeta)
const TAKEN = checkAnswer([], 'ready', { available: false, suggestion: 'bathroom-costs-2' })
async function typeSlug(panel: HTMLElement, value: string) {
panel.querySelector<HTMLElement>('button[aria-label="Edit slug"]')!.click()
await flushPromises()
const slug = fieldByLabel(panel, 'URL slug')!
await typeInto(slug, value)
return slug
}
function slugButtons(panel: HTMLElement) {
return Array.from(panel.querySelectorAll<HTMLButtonElement>('button')).map((button) =>
textOf(button),
)
}
describe('Slug availability is checked on blur', () => {
it('sends one check with the typed slug and shows the taken state with the suggestion', async () => {
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
const before = checkCalls(backend).length
answerChecks(backend, TAKEN)
const slug = await typeSlug(panel, 'bathroom-costs')
await blur(slug)
expect(checkCalls(backend)).toHaveLength(before + 1)
expect(checkBodies(backend).at(-1)).toEqual({ slug: 'bathroom-costs' })
expect(textOf(panel)).toContain('The slug "bathroom-costs" is already used on Renew.')
expect(slugButtons(panel)).toContain('Use bathroom-costs-2')
})
it('puts the suggested slug in manual mode, clears the error and the action and checks it', async () => {
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
answerChecks(backend, TAKEN)
const slug = await typeSlug(panel, 'bathroom-costs')
await blur(slug)
const before = checkCalls(backend).length
answerChecks(backend, checkAnswer([]))
await click('Use bathroom-costs-2', panel)
const field = fieldByLabel(panel, 'URL slug')!
expect(field.value).toBe('bathroom-costs-2')
expect(field.readOnly).toBe(false)
expect(textOf(panel)).not.toContain('is already used on Renew.')
expect(slugButtons(panel)).not.toContain('Use bathroom-costs-2')
expect(textOf(panel)).toContain('Reset to match the title')
expect(checkCalls(backend)).toHaveLength(before + 1)
expect(checkBodies(backend).at(-1)).toEqual({ slug: 'bathroom-costs-2' })
})
it('shows neither an error nor an action for a slug the article already owns', async () => {
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
answerChecks(backend, checkAnswer([], 'ready', { available: true, suggestion: null }))
const slug = await typeSlug(panel, 'bathroom-costs')
await blur(slug)
expect(textOf(panel)).not.toContain('is already used')
expect(slugButtons(panel).some((label) => label.startsWith('Use '))).toBe(false)
})
})
describe('Slug taken on save', () => {
it('keeps the panel open with the taken state and the suggestion of a follow-up check, applying nothing', async () => {
const backend = await mountSeo(seoBackend())
const panel = await openPanel()
const slug = await typeSlug(panel, 'bathroom-costs')
await typeInto(fieldByLabel(panel, 'Meta title')!, 'A kept title')
backend.responders['PATCH /v1/article/12'] = failWith(409, 'slug_taken')
answerChecks(backend, TAKEN)
void slug
saveChangesButton(panel).click()
await flushPromises()
expect(panelRoot()).not.toBeNull()
expect(textOf(panel)).toContain('The slug "bathroom-costs" is already used on Renew.')
expect(slugButtons(panel)).toContain('Use bathroom-costs-2')
expect(fieldByLabel(panel, 'Meta title')!.value).toBe('A kept title')
expect(checkBodies(backend).at(-1)).toEqual({
slug: 'bathroom-costs',
seo_title: 'A kept title',
})
expect(patchBodies(backend)).toEqual([
{ seo_title: 'A kept title', slug: 'bathroom-costs' },
])
expect(document.body.textContent).toContain('Unsaved changes')
expect(document.body.textContent).not.toContain('Save failed')
})
})
@@ -0,0 +1,332 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import {
click,
failWith,
networkFailure,
pending,
renewSites,
textOf,
unmountMeta,
} from './metaTestKit'
import {
ARTICLE_SLUG,
ARTICLE_TITLE,
FOUR_SUGGESTIONS,
answerChecks,
checkAnswer,
checkCalls,
mountSeo,
openSection,
panelRoot,
patchBodies,
renewSite,
sectionBody,
sectionSummary,
seoBackend,
suggestion,
} from './seoTestKit'
const emptyRectList = () => Object.assign([], { item: () => null }) as unknown as DOMRectList
Range.prototype.getClientRects = emptyRectList
Range.prototype.getBoundingClientRect = () => new DOMRect()
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(unmountMeta)
function suggestionRows() {
return Array.from(sectionBody()!.querySelectorAll<HTMLElement>('[data-testid="suggestion"]'))
}
function toasts() {
return textOf(document.body.querySelector('[data-slot="viewport"]'))
}
describe('The open section: address, suggestions and actions', () => {
it('shows the address, All good and the two actions when the check is ready', async () => {
await mountSeo(seoBackend())
const body = await openSection()
const text = textOf(body)
expect(text).toContain('Address')
expect(text).toContain(`renew.example/${ARTICLE_SLUG}`)
expect(text).not.toContain('https://')
expect(text).toContain('All good')
expect(text).toContain('Nothing to suggest for this article.')
expect(text).toContain('Edit search and sharing')
expect(text).toContain('What the frontend gets')
})
it('shows the host of the article site and the slug for another site', async () => {
const journal = renewSite({
...renewSites()[1]!,
site_name: 'Journal',
title_template: '%title% | %site%',
base_url: 'https://journal.renew.example',
})
const backend = seoBackend({ sites: [renewSite(), journal], article: { site_id: 2 } })
await mountSeo(backend)
const body = await openSection()
expect(textOf(body)).toContain(`journal.renew.example/${ARTICLE_SLUG}`)
})
it('shows at most three suggestions, in the order of the response, each with its link', async () => {
const backend = seoBackend()
answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS))
await mountSeo(backend)
await openSection()
const rows = suggestionRows()
expect(rows).toHaveLength(3)
expect(rows.map((row) => textOf(row))).toEqual([
'The meta title is 74 characters. Search engines may cut it off after about 60. Go to meta title',
'The cover image has no alt text. Add a short description of it. Go to cover image',
'The body has no headings. H2 headings help readers scan the article. Go to body',
])
expect(textOf(sectionBody())).toContain('+1 more in Search and sharing')
})
it('opens the panel with the suggestion list in view from "+1 more in Search and sharing"', async () => {
const backend = seoBackend()
answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS))
await mountSeo(backend)
await openSection()
await click('+1 more in Search and sharing')
await new Promise((resolve) => setTimeout(resolve, 0))
await flushPromises()
const panel = panelRoot()!
expect(panel).not.toBeNull()
const suggestions = panel.querySelector('[data-testid="seo-panel-suggestions"]')!
expect(document.activeElement).toBe(suggestions)
expect(textOf(suggestions)).toContain('The body is 40 characters.')
})
it('shows no "+N more" action with three suggestions or fewer', async () => {
const backend = seoBackend()
answerChecks(backend, checkAnswer(FOUR_SUGGESTIONS.slice(0, 3)))
await mountSeo(backend)
await openSection()
expect(suggestionRows()).toHaveLength(3)
expect(textOf(sectionBody())).not.toContain('more in Search and sharing')
})
it('shows Checking this article… and keeps the address and both actions usable while the check is pending', async () => {
const backend = seoBackend()
const gate = pending<unknown>()
answerChecks(backend, () => gate.promise)
await mountSeo(backend)
const body = await openSection()
const text = textOf(body)
expect(text).toContain('Checking this article…')
expect(text).toContain(`renew.example/${ARTICLE_SLUG}`)
const edit = body.querySelector<HTMLButtonElement>('[data-testid="seo-edit"]')!
const preview = body.querySelector<HTMLButtonElement>('[data-testid="seo-preview"]')!
expect(edit.disabled).toBe(false)
expect(preview.disabled).toBe(false)
})
it('explains the flag when the article is hidden from search', async () => {
const backend = seoBackend({ article: { noindex: true } })
answerChecks(backend, checkAnswer([], 'hidden'))
await mountSeo(backend)
const body = await openSection()
expect(textOf(body)).toContain(
'Hidden from search engines. This only sets a flag in the API.',
)
expect(sectionSummary()).toBeNull()
})
})
describe('Where a suggestion link goes', () => {
async function followLink(field: Parameters<typeof suggestion>[1], label: string) {
const backend = seoBackend({ article: { cover_image_id: 1 } })
answerChecks(
backend,
checkAnswer([suggestion('title_long', field, { length: 74, max: 60 })]),
)
await mountSeo(backend)
await openSection()
await click(label)
await new Promise((resolve) => setTimeout(resolve, 100))
await flushPromises()
return backend
}
it.each([
{
field: 'seo_title',
label: 'Go to meta title',
focused: (panel: HTMLElement) =>
panel.querySelector(`[placeholder="${ARTICLE_TITLE}"]`),
},
{
field: 'seo_description',
label: 'Go to meta description',
focused: (panel: HTMLElement) => panel.querySelector('textarea[maxlength="320"]'),
},
{
field: 'cover_image_alt',
label: 'Go to cover image',
focused: (panel: HTMLElement) =>
panel.querySelector(
'[placeholder="Describe the image for people who can\'t see it"]',
),
},
{
field: 'slug',
label: 'Go to URL slug',
focused: (panel: HTMLElement) => panel.querySelector('input[maxlength="100"]'),
},
] as const)(
'opens the panel with focus on the $field field',
async ({ field, label, focused }) => {
await followLink(field, label)
const panel = panelRoot()!
expect(panel).not.toBeNull()
expect(document.activeElement).toBe(focused(panel))
expect(document.activeElement).not.toBeNull()
},
)
it('opens the panel with focus on the cover Choose image button', async () => {
await followLink('cover_image_id', 'Go to cover image')
const active = document.activeElement as HTMLElement
expect(panelRoot()!.contains(active)).toBe(true)
expect(textOf(active)).toMatch(/Choose image|Change image/)
})
it('moves focus to the article body for a body suggestion', async () => {
const focused = vi.spyOn(HTMLElement.prototype, 'focus')
await followLink('body', 'Go to body')
await new Promise((resolve) => setTimeout(resolve, 50))
expect(panelRoot()).toBeNull()
expect(
focused.mock.contexts.some((target) =>
(target as HTMLElement).classList.contains('ProseMirror'),
),
).toBe(true)
})
it('opens Site and authors and moves focus to its list for an authors suggestion', async () => {
await followLink('author_ids', 'Go to authors')
expect(panelRoot()).toBeNull()
const meta = document.body.querySelector('[data-testid="meta-section"]')!
expect(meta.querySelector('[data-testid="meta-body"]')).not.toBeNull()
expect(meta.contains(document.activeElement)).toBe(true)
})
})
describe('The messages follow the params of the response', () => {
it.each([
[
'title_long',
'seo_title',
{ length: 74, max: 60 },
'The meta title is 74 characters. Search engines may cut it off after about 60.',
],
[
'desc_short',
'seo_description',
{ length: 41, min: 70 },
'The description is 41 characters. About 70 to 160 reads best in results.',
],
[
'cover_alt',
'cover_image_alt',
{},
'The cover image has no alt text. Add a short description of it.',
],
[
'no_h',
'body',
{},
'The body has no headings. H2 headings help readers scan the article.',
],
['title_empty', 'seo_title', {}, 'The meta title is empty.'],
[
'desc_long',
'seo_description',
{ length: 170, max: 160 },
'The description is 170 characters. About 70 to 160 reads best in results.',
],
['cover_none', 'cover_image_id', {}, 'This article has no cover image.'],
[
'slug_long',
'slug',
{ length: 80, max: 75 },
'The URL slug is 80 characters. Shorter addresses are easier to share.',
],
['no_au', 'author_ids', {}, 'This article has no author.'],
['body_alt', 'body', { count: 2 }, '2 images in the body have no alt text.'],
['body_alt', 'body', { count: 1 }, '1 image in the body has no alt text.'],
[
'short',
'body',
{ length: 40, min: 300 },
'The body is 40 characters. Longer articles tend to do better in search.',
],
] as const)('renders %s with %j', async (code, field, params, message) => {
const backend = seoBackend()
answerChecks(backend, checkAnswer([suggestion(code, field, { ...params })]))
await mountSeo(backend)
await openSection()
expect(textOf(suggestionRows()[0])).toContain(message)
})
})
describe('The check could not run', () => {
it.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])(
"shows Couldn't check this article. with Try again and requests the check again on %s",
async (_name, failure) => {
const backend = seoBackend()
backend.responders['POST /v1/article/12/seo/check'] = failure
await mountSeo(backend)
const body = await openSection()
expect(textOf(body)).toContain("Couldn't check this article.")
expect(textOf(body)).not.toContain('All good')
expect(toasts()).toBe('')
expect(patchBodies(backend)).toEqual([])
const callsBefore = checkCalls(backend).length
backend.responders['POST /v1/article/12/seo/check'] = () => checkAnswer([])
await click('Try again')
expect(checkCalls(backend)).toHaveLength(callsBefore + 1)
expect(textOf(body)).toContain('All good')
},
)
it('shows the Not found editor state when the check answers 404', async () => {
const backend = seoBackend()
backend.responders['POST /v1/article/12/seo/check'] = failWith(404)
await mountSeo(backend)
expect(document.body.textContent).toContain("This article doesn't exist.")
})
})
@@ -8,7 +8,7 @@ import type { Article } from '@/composables/useArticles'
import ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
import { messages } from '@/i18n/messages'
import { articleMeta, oneSite } from './editorMetaKit'
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
import { fixture, textOf } from './mediaTestKit'
const FIXED_NOW = '2026-09-26T11:02:00Z'
@@ -127,6 +127,9 @@ async function mountEditor(options: EditorOptions = {}) {
if (url === '/v1/site/') {
return reply(200, oneSite)
}
if (url === '/v1/article/12/seo/check') {
return reply(200, readyCheck)
}
if (url === '/v1/media/?page=1&size=25') {
return reply(200, libraryPage())
}
@@ -158,7 +161,10 @@ function variantRequests() {
}
function writeRequests() {
return fetchMock.mock.calls.filter(([, init]) => (init as RequestInit | undefined)?.method)
return fetchMock.mock.calls.filter(
([url, init]) =>
(init as RequestInit | undefined)?.method && !String(url).endsWith('/seo/check'),
)
}
function findButton(text: string, root: ParentNode = document.body) {
+17 -4
View File
@@ -8,7 +8,7 @@ import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
import { articleMeta, oneSite } from './editorMetaKit'
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
const FIXED_NOW = '2026-09-26T11:02:00Z'
@@ -29,6 +29,7 @@ function routeFor(url: string, init?: RequestInit) {
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
const routes = {
'GET /v1/site/': () => mockResponse(200, oneSite),
'POST /v1/article/12/seo/check': () => mockResponse(200, readyCheck),
...handlers,
}
return vi.fn((url: string, init?: RequestInit) => {
@@ -690,7 +691,11 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog
expect(props.confirmLabel).toBe('Remove image')
expect(props.cancelLabel).toBe('Cancel')
expect(props.tone).toBe('neutral')
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html', 'GET /v1/site/'])
expect(requestCalls(wrapper)).toEqual([
'GET /v1/article/12?format=html',
'GET /v1/site/',
'POST /v1/article/12/seo/check',
])
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
})
@@ -743,7 +748,11 @@ describe('Cancelling the confirm changes nothing', () => {
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
expect(wrapper.text()).not.toContain('Unsaved changes')
expect(toastAdd).not.toHaveBeenCalled()
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html', 'GET /v1/site/'])
expect(requestCalls(wrapper)).toEqual([
'GET /v1/article/12?format=html',
'GET /v1/site/',
'POST /v1/article/12/seo/check',
])
})
})
@@ -767,7 +776,11 @@ describe('Confirming removes the image from the body only, marks the draft dirty
title: 'Image removed',
description: "It's still in your library.",
})
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html', 'GET /v1/site/'])
expect(requestCalls(wrapper)).toEqual([
'GET /v1/article/12?format=html',
'GET /v1/site/',
'POST /v1/article/12/seo/check',
])
})
})
@@ -7,7 +7,7 @@ import router from '@/router'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
import { useSessionStore } from '@/stores/session'
import { articleMeta, oneSite } from './editorMetaKit'
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
type Handler = () => Response
@@ -36,6 +36,7 @@ let wrapper: ReturnType<typeof mount> | null = null
function stubFetch(handlers: Record<string, Handler>) {
const routes: Record<string, Handler> = {
'GET /v1/site/': () => reply(200, oneSite),
'POST /v1/article/12/seo/check': () => reply(200, readyCheck),
'GET /v1/article/12?format=html': () => reply(200, article),
...handlers,
}
+6
View File
@@ -65,3 +65,9 @@ export const leaFontaine: Byline = {
}
export const articleMeta = { site_id: defaultSite.id, authors: [ines] }
export const readyCheck = {
status: 'ready',
suggestions: [],
slug: { available: true, suggestion: null },
}
+17
View File
@@ -9,6 +9,7 @@ import type { Article, Byline } from '@/composables/useArticles'
import type { Author } from '@/composables/useAuthors'
import type { Site } from '@/composables/useSites'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { SEO_KEYS } from '@/lib/articleSeo'
import { slugify } from '@/lib/directory'
import { useSessionStore } from '@/stores/session'
import {
@@ -42,6 +43,14 @@ export interface MetaBackend extends Backend {
meSlugAdjusted?: boolean
}
export function readyCheck() {
return {
status: 'ready',
suggestions: [],
slug: { available: true, suggestion: null },
}
}
export const MARTA_EMAIL = 'marta@renew.example'
const ARTICLE_ID = 12
@@ -146,6 +155,11 @@ function patchArticle(backend: MetaBackend, body: Record<string, unknown>) {
if (typeof body.body === 'string') {
backend.article.body = body.body
}
for (const key of SEO_KEYS) {
if (key in body) {
Object.assign(backend.article, { [key]: body[key] })
}
}
return structuredClone(backend.article)
}
@@ -206,6 +220,9 @@ function createMyAuthor(backend: MetaBackend) {
function route(backend: MetaBackend, call: Call): unknown {
const { method, path, query, body } = call
const articleMatch = /^\/v1\/article\/(\d+)$/.exec(path)
if (/^\/v1\/article\/\d+\/seo\/check$/.test(path) && method === 'POST') {
return readyCheck()
}
if (articleMatch && method === 'GET') {
return structuredClone(backend.article)
}
+223
View File
@@ -0,0 +1,223 @@
import { flushPromises } from '@vue/test-utils'
import type { Call } from './directoryTestKit'
import {
createMetaBackend,
mountMeta,
renewSites,
textOf,
type MetaBackend,
type MetaMountOptions,
} from './metaTestKit'
import type { SeoSuggestion, SuggestionCode } from '@/lib/seoTypes'
import type { Site } from '@/composables/useSites'
export const EDITOR_URL = '/article/12/edit'
export const ARTICLE_TITLE = 'How we price a bathroom renovation'
export const ARTICLE_SLUG = 'how-we-price-a-bathroom-renovation'
export const FIRST_SENTENCE =
'A bathroom renovation is priced by room size, plumbing changes and finish level.'
export const ARTICLE_BODY = `<p>${FIRST_SENTENCE} Every project starts with a visit.</p><h2>What goes into the price</h2><p>Stripping out and making good.</p>`
export const LONG_TITLE =
'How we price a bathroom renovation: surveys, plumbing, tiling and finishes'
export const NORTH_WALL = {
id: 1,
filename: 'north-wall.jpg',
url: '/static/uploadedImages/north-wall.jpg',
size: 4089446,
content_type: 'image/jpeg',
width: 4032,
height: 3024,
sha256: 'a'.repeat(64),
uploaded_by: 'Marta Alves',
created_at: '2026-09-12T12:00:00Z',
used_in: 1,
duplicate: false,
variant_count: 0,
variant_size: 0,
}
export const KITCHEN_AFTER = { ...NORTH_WALL, id: 2, filename: 'kitchen-after.webp' }
export const READY = {
status: 'ready',
suggestions: [],
slug: { available: true, suggestion: null },
}
export function suggestion(
code: SuggestionCode,
field: SeoSuggestion['field'],
params: Record<string, number> = {},
): SeoSuggestion {
return { code, field, params }
}
export function checkAnswer(
suggestions: SeoSuggestion[],
status: 'ready' | 'suggestions' | 'hidden' = suggestions.length ? 'suggestions' : 'ready',
slug: { available: boolean; suggestion: string | null } = { available: true, suggestion: null },
) {
return { status, suggestions, slug }
}
export const FOUR_SUGGESTIONS = [
suggestion('title_long', 'seo_title', { length: 74, max: 60 }),
suggestion('cover_alt', 'cover_image_alt'),
suggestion('no_h', 'body'),
suggestion('short', 'body', { length: 40, min: 300 }),
]
export function renewSite(overrides: Partial<Site> = {}): Site {
return {
...renewSites()[0]!,
site_name: 'Renew',
title_template: '%title% | %site%',
default_description: '',
...overrides,
}
}
export function seoBackend(
options: {
article?: Partial<MetaBackend['article']>
sites?: Site[]
check?: unknown
role?: 'owner' | 'admin' | 'verified'
} = {},
) {
const backend = createMetaBackend({
sites: options.sites ?? [renewSite()],
role: options.role,
})
Object.assign(backend.article, {
title: ARTICLE_TITLE,
body: ARTICLE_BODY,
slug: ARTICLE_SLUG,
excerpt: '',
seo_title: '',
seo_description: '',
cover_image_id: null,
cover_image_alt: '',
social_title: '',
social_description: '',
social_image_id: null,
canonical_url: '',
noindex: false,
...options.article,
})
backend.responders['POST /v1/article/12/seo/check'] = () => options.check ?? READY
backend.responders['GET /v1/media/'] = () => ({
items: [NORTH_WALL, KITCHEN_AFTER],
total: 2,
page: 1,
size: 100,
counts: { all: 2, unused: 0, duplicates: 0 },
})
return backend
}
export async function mountSeo(backend: MetaBackend, options: MetaMountOptions = {}) {
await mountMeta('editor', EDITOR_URL, { ...options, backend })
return backend
}
export function checkCalls(backend: MetaBackend) {
return backend.calls.filter(
(call) => call.method === 'POST' && call.path.endsWith('/seo/check'),
)
}
export function checkBodies(backend: MetaBackend) {
return checkCalls(backend).map((call) => call.body)
}
export function patchBodies(backend: MetaBackend) {
return backend.calls
.filter((call) => call.method === 'PATCH' && /^\/v1\/article\/\d+$/.test(call.path))
.map((call) => call.body)
}
export function seoCalls(backend: MetaBackend) {
return backend.calls.filter((call) => call.method === 'GET' && call.path.endsWith('/seo'))
}
export function answerChecks(backend: MetaBackend, answer: unknown | ((call: Call) => unknown)) {
backend.responders['POST /v1/article/12/seo/check'] = (call) =>
typeof answer === 'function' ? (answer as (call: Call) => unknown)(call) : answer
}
export function section(root: ParentNode = document.body) {
return root.querySelector<HTMLElement>('[data-testid="seo-section"]')
}
export function sectionToggle(root: ParentNode = document.body) {
return root.querySelector<HTMLElement>('[data-testid="seo-toggle"]')!
}
export function sectionSummary(root: ParentNode = document.body) {
return root.querySelector<HTMLElement>('[data-testid="seo-summary"]')
}
export function sectionBody(root: ParentNode = document.body) {
return root.querySelector<HTMLElement>('[data-testid="seo-body"]')
}
export async function openSection() {
if (!sectionBody()) {
sectionToggle().click()
await flushPromises()
}
return sectionBody()!
}
export function panelRoot() {
return (
document.body
.querySelector<HTMLElement>('[data-testid="seo-panel-suggestions"]')
?.closest('[role="dialog"]') ?? null
)
}
export async function openPanel() {
await openSection()
const edit = document.body.querySelector<HTMLElement>('[data-testid="seo-edit"]')!
edit.focus()
edit.click()
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 0))
await flushPromises()
return panelRoot()!
}
export async function openDisclosure(title: string) {
const toggle = Array.from(
panelRoot()!.querySelectorAll<HTMLElement>('[data-testid="seo-disclosure-toggle"]'),
).find((button) => textOf(button).startsWith(title))!
toggle.click()
await flushPromises()
}
export function inputByPlaceholder(root: ParentNode, placeholder: string) {
return root.querySelector<HTMLInputElement | HTMLTextAreaElement>(
`[placeholder="${placeholder}"]`,
)!
}
export function fieldByLabel(root: ParentNode, label: string) {
const element = Array.from(root.querySelectorAll('label')).find((candidate) =>
textOf(candidate).startsWith(label),
)
const id = element?.getAttribute('for')
return id ? (document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement) : null
}
export function counterTexts(root: ParentNode) {
return Array.from(root.querySelectorAll('[data-testid="soft-counter"]')).map((node) =>
textOf(node),
)
}
export function saveChangesButton(root: ParentNode) {
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
(candidate) => textOf(candidate) === 'Save changes' || textOf(candidate) === 'Saving…',
)!
}