feat: SEO SPA Sites, Articles and Integration (4/4)
This commit is contained in:
1 parent
cb737b7ae3
commit
02fb8751fd
47 files changed
+3218
-227
No files matched your search
@@ -0,0 +1,284 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import type { SuggestionCode } from '@/lib/seoTypes'
|
||||
import { failWith, networkFailure, pending, textOf, unmountMeta } from './metaTestKit'
|
||||
import {
|
||||
checkAnswer,
|
||||
checkCalls,
|
||||
mountSeo,
|
||||
openPanel,
|
||||
openSection,
|
||||
patchBodies,
|
||||
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)
|
||||
|
||||
const TITLE_MESSAGE =
|
||||
'The meta title is 74 characters. Search engines may cut it off after about 60.'
|
||||
|
||||
const ALL = [
|
||||
suggestion('title_long', 'seo_title', { length: 74, max: 60 }),
|
||||
suggestion('cover_alt', 'cover_image_alt'),
|
||||
suggestion('no_h', 'body'),
|
||||
]
|
||||
|
||||
function backendWithDismissals(saved: SuggestionCode[] = [], all = ALL) {
|
||||
const backend = seoBackend({ article: { seo_dismissed: saved } })
|
||||
const state = { saved }
|
||||
backend.responders['POST /v1/article/12/seo/check'] = () =>
|
||||
checkAnswer(all.filter((item) => !state.saved.includes(item.code)))
|
||||
backend.responders['PATCH /v1/article/12'] = (call) => {
|
||||
state.saved = [...new Set(call.body!.seo_dismissed as SuggestionCode[])]
|
||||
return { ...backend.article, seo_dismissed: state.saved }
|
||||
}
|
||||
return backend
|
||||
}
|
||||
|
||||
function dismissButtons(root: ParentNode = document.body) {
|
||||
return Array.from(
|
||||
root.querySelectorAll<HTMLButtonElement>('[data-testid="suggestion-dismiss"]'),
|
||||
)
|
||||
}
|
||||
|
||||
function panelRows(root: ParentNode) {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>('[data-testid="suggestion"]'))
|
||||
}
|
||||
|
||||
async function dismiss(index: number) {
|
||||
dismissButtons()[index]!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function toastText() {
|
||||
return textOf(document.body.querySelector('[data-slot="viewport"]'))
|
||||
}
|
||||
|
||||
describe('Dismiss x: rendering', () => {
|
||||
it('draws one button per panel row and a footnote, and none in the compact list', async () => {
|
||||
const backend = backendWithDismissals()
|
||||
await mountSeo(backend)
|
||||
const compact = await openSection()
|
||||
expect(dismissButtons(compact)).toHaveLength(0)
|
||||
expect(textOf(compact)).not.toContain('Dismiss one and it stays hidden')
|
||||
|
||||
const panel = await openPanel()
|
||||
expect(dismissButtons(panel)).toHaveLength(3)
|
||||
expect(textOf(panel)).toContain(
|
||||
'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.',
|
||||
)
|
||||
})
|
||||
|
||||
it('names the button with the message and orders it after the Go to link', async () => {
|
||||
await mountSeo(backendWithDismissals())
|
||||
const panel = await openPanel()
|
||||
|
||||
const first = dismissButtons(panel)[0]!
|
||||
expect(first.getAttribute('aria-label')).toBe(`Dismiss suggestion: ${TITLE_MESSAGE}`)
|
||||
expect(first.className).toContain('focus-visible:outline-2')
|
||||
const row = panelRows(panel)[0]!
|
||||
const link = Array.from(row.querySelectorAll('button')).find((button) =>
|
||||
textOf(button).startsWith('Go to'),
|
||||
)!
|
||||
expect(link.compareDocumentPosition(first) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
)
|
||||
})
|
||||
|
||||
it('is 44 px on mobile and 24 px on desktop', async () => {
|
||||
await mountSeo(backendWithDismissals(), { width: MOBILE_WIDTH })
|
||||
const panel = await openPanel()
|
||||
|
||||
expect(dismissButtons(panel)[0]!.className).toContain('size-11')
|
||||
expect(dismissButtons(panel)[0]!.className).toContain('md:size-6')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dismiss x: behaviour', () => {
|
||||
it('sends one PATCH with the saved list plus the code and nothing else', async () => {
|
||||
const backend = backendWithDismissals(['no_au'])
|
||||
await mountSeo(backend)
|
||||
await openPanel()
|
||||
const before = checkCalls(backend).length
|
||||
await dismiss(0)
|
||||
|
||||
expect(patchBodies(backend)).toEqual([{ seo_dismissed: ['no_au', 'title_long'] }])
|
||||
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||
})
|
||||
|
||||
it('keeps the row and shows a busy button until the PATCH answers', async () => {
|
||||
const backend = backendWithDismissals()
|
||||
const hold = pending<unknown>()
|
||||
const answer = backend.responders['PATCH /v1/article/12']!
|
||||
backend.responders['PATCH /v1/article/12'] = async (call) => {
|
||||
await hold.promise
|
||||
return answer(call)
|
||||
}
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
await dismiss(0)
|
||||
|
||||
expect(panelRows(panel)).toHaveLength(3)
|
||||
expect(dismissButtons(panel)[0]!.disabled).toBe(true)
|
||||
expect(dismissButtons(panel)[0]!.getAttribute('aria-busy')).toBe('true')
|
||||
hold.resolve(undefined)
|
||||
await flushPromises()
|
||||
expect(panelRows(panel)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('removes the row, keeps the order and updates the summary', async () => {
|
||||
const backend = backendWithDismissals()
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
await dismiss(0)
|
||||
|
||||
const rows = panelRows(panel).map(textOf)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]).toContain('The cover image has no alt text')
|
||||
expect(rows[1]).toContain('The body has no headings')
|
||||
})
|
||||
|
||||
it('shows All good with the dismissed line when the last one goes, and focuses the link', async () => {
|
||||
const backend = backendWithDismissals([], [ALL[0]!])
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
await dismiss(0)
|
||||
|
||||
const text = textOf(panel)
|
||||
expect(text).toContain('All good')
|
||||
expect(text).toContain('1 dismissed suggestion is hidden.')
|
||||
const restore = panel.querySelector<HTMLElement>('[data-testid="suggestions-restore"]')!
|
||||
expect(textOf(restore)).toBe('Show dismissed')
|
||||
expect(document.activeElement).toBe(restore)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ position: 'first', index: 0, target: 0 },
|
||||
{ position: 'second', index: 1, target: 1 },
|
||||
{ position: 'last', index: 2, target: 1 },
|
||||
])('moves focus after dismissing the $position row', async ({ index, target }) => {
|
||||
const backend = backendWithDismissals()
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
await dismiss(index)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(dismissButtons(panel)[target])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ failure: 'status 500', responder: failWith(500) },
|
||||
{ failure: 'a network error', responder: networkFailure },
|
||||
])('keeps the row and toasts on $failure', async ({ responder }) => {
|
||||
const backend = backendWithDismissals()
|
||||
backend.responders['PATCH /v1/article/12'] = responder
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
const before = checkCalls(backend).length
|
||||
await dismiss(0)
|
||||
|
||||
expect(panelRows(panel)).toHaveLength(3)
|
||||
expect(dismissButtons(panel)[0]!.disabled).toBe(false)
|
||||
expect(toastText()).toContain("Couldn't dismiss the suggestion")
|
||||
expect(toastText()).toContain('Try again in a moment.')
|
||||
expect(checkCalls(backend)).toHaveLength(before)
|
||||
})
|
||||
|
||||
it('shows the Not found state on a 404', async () => {
|
||||
const backend = backendWithDismissals()
|
||||
backend.responders['PATCH /v1/article/12'] = failWith(404)
|
||||
await mountSeo(backend)
|
||||
await openPanel()
|
||||
await dismiss(0)
|
||||
|
||||
expect(textOf(document.body)).toContain("This article doesn't exist.")
|
||||
})
|
||||
|
||||
it('shows nothing and sends no check on a 401', async () => {
|
||||
const backend = backendWithDismissals()
|
||||
backend.responders['PATCH /v1/article/12'] = failWith(401)
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
const before = checkCalls(backend).length
|
||||
const toastsBefore = toastText()
|
||||
await dismiss(0)
|
||||
|
||||
expect(panelRows(panel)).toHaveLength(3)
|
||||
expect(toastText()).toBe(toastsBefore)
|
||||
expect(checkCalls(backend)).toHaveLength(before)
|
||||
})
|
||||
|
||||
it('restores every code with Show dismissed and checks again', async () => {
|
||||
const backend = backendWithDismissals(['title_long', 'short'], [ALL[0]!])
|
||||
await mountSeo(backend)
|
||||
const compact = await openSection()
|
||||
expect(textOf(compact)).toContain('2 dismissed suggestions are hidden.')
|
||||
const link = compact.querySelector<HTMLElement>('[data-testid="suggestions-restore"]')!
|
||||
link.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchBodies(backend)).toEqual([{ seo_dismissed: [] }])
|
||||
expect(sectionBody()!.querySelectorAll('[data-testid="suggestion"]')).toHaveLength(1)
|
||||
expect(sectionSummary()).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['owner', 'admin', 'verified'] as const)('is available to the %s', async (role) => {
|
||||
const backend = backendWithDismissals()
|
||||
backend.me.role = role === 'verified' ? undefined : role
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
|
||||
expect(dismissButtons(panel)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('keeps Save enabled and does not touch the draft', async () => {
|
||||
const backend = backendWithDismissals()
|
||||
await mountSeo(backend)
|
||||
await openPanel()
|
||||
await dismiss(0)
|
||||
|
||||
expect(patchBodies(backend)).toHaveLength(1)
|
||||
const save = document.body.querySelector<HTMLButtonElement>('[data-testid="save-button"]')
|
||||
expect(save?.getAttribute('data-state') ?? 'saved').not.toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dismiss x: French', () => {
|
||||
it('reads the name, footnote and plural in French', async () => {
|
||||
const { setLocale } = await import('@/i18n')
|
||||
setLocale('fr')
|
||||
try {
|
||||
await mountSeo(backendWithDismissals(['no_au', 'short'], ALL))
|
||||
const panel = await openPanel()
|
||||
|
||||
expect(dismissButtons(panel)[0]!.getAttribute('aria-label')).toMatch(
|
||||
/^Ignorer la suggestion : /,
|
||||
)
|
||||
expect(textOf(panel)).toContain(
|
||||
'Ignorez-en une et elle reste masquée pour cet article.',
|
||||
)
|
||||
expect(textOf(panel)).toContain('2 suggestions ignorées sont masquées.')
|
||||
} finally {
|
||||
setLocale('en')
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user