feat: SEO SPA editor integration
This commit is contained in:
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()
|
||||
},
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user