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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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…',
|
||||
)!
|
||||
}
|
||||
Reference in new issue
Block a user