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() 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('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('[role="dialog"]')! sheet.querySelector('[data-testid="seo-toggle"]')!.click() await flushPromises() sheet.querySelector('[data-testid="seo-edit"]')!.click() await flushPromises() await new Promise((resolve) => setTimeout(resolve, 0)) await flushPromises() const panel = Array.from( document.body.querySelectorAll('[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('[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('[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 = { '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('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('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('button')).find( (button) => textOf(button) === 'Choose image', )! choose.click() await flushPromises() const dialogs = Array.from(document.body.querySelectorAll('[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('[data-picker-tile]')!.click() await flushPromises() Array.from(picker.querySelectorAll('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('[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: true }, { value: 'https://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('[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('[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('[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 }) })