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() 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('[data-testid="suggestion-dismiss"]'), ) } function panelRows(root: ParentNode) { return Array.from(root.querySelectorAll('[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() 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('[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('[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('[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') } }) })