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
@@ -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
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user