feat: SEO SPA editor integration

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

No files matched your search

@@ -0,0 +1,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()
},
)
})