286 lines
9.5 KiB
TypeScript
286 lines
9.5 KiB
TypeScript
import { accessFor } from '@/test/access'
|
|
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.access = accessFor(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')
|
|
}
|
|
})
|
|
})
|