Files
sm-seo-plugin/tests/elements/serp.test.ts
Jakub Zych 3ecbd1f711 fix: the result preview and the robots.txt tester survive a dropped first action (CR-04)
Both elements sent their first summer-action from connectedCallback and
kept it in flight forever when the host dropped it (the v0.5.1 widget host
appends the element before it keeps a reference). A shared ActionQueue no
longer trusts that an emitted action was taken: one the host did not mark
busy synchronously is retried once on the next task, a second drop shows
an error, and a disconnect forgets the request in flight. The first
request is sent on a microtask. Element tests drive a fake host that
mirrors WidgetField's contract, including the append-first ordering, and
the robots.txt tester gets its own tests.
2026-10-27 15:24:33 +01:00

252 lines
11 KiB
TypeScript

import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { HostFailure, mountWidget, settle } from './host'
// E3 golem15-seo-serp (UI-SPEC C6): the effective title, truncation at a
// word boundary within the pixel budget (mocked canvas: 10 px per
// character), and the Q-4 keyphrase stem matching.
HTMLCanvasElement.prototype.getContext = function () {
return { font: '', measureText: (text: string) => ({ width: Array.from(text).length * 10 }) } as unknown as CanvasRenderingContext2D
} as unknown as typeof HTMLCanvasElement.prototype.getContext
let serp: typeof import('../../assets/src/elements/serp')
let measure: typeof import('../../assets/src/elements/measure')
let labels: typeof import('../../assets/src/elements/labels')
beforeAll(async () => {
measure = await import('../../assets/src/elements/measure')
serp = await import('../../assets/src/elements/serp')
labels = await import('../../assets/src/elements/labels')
await import('../../assets/src/elements/index')
})
afterEach(() => {
document.body.replaceChildren()
})
type ContextLocale = import('../../assets/src/elements/serp').ContextLocale
function contextLocale(locale: Partial<ContextLocale> = {}): ContextLocale {
return { locale: 'en', url: '/blog/post', h1: null, siteName: 'Atlas', titleTemplate: '', defaultTitle: 'Post', defaultDescription: null, descriptionSource: 'content', bodyWords: null, ...locale }
}
/**
* A serp element given one context locale directly (rendering tests); the
* host protocol is covered by 'E3 host protocol' below.
*/
function preview(locale: Partial<ContextLocale>, fill: Record<string, unknown> = {}): HTMLElement {
const node = document.createElement('golem15-seo-serp')
node.setAttribute('ui-locale', 'en')
node.setAttribute('fill-values', JSON.stringify(fill))
document.body.append(node)
node.dispatchEvent(new CustomEvent('summer-result', { detail: { data: { op: 'context', host: 'example.test', locales: [contextLocale(locale)] } } }))
return node
}
function shownTitle(node: HTMLElement): string {
return node.querySelector('.serp-card__title')?.textContent ?? ''
}
describe('E3 effective title', () => {
it('an empty SEO title applies the type template to defaultTitle', () => {
const node = preview({ titleTemplate: '%title% — Blog | %site%', defaultTitle: 'Grzyby leśne' })
expect(shownTitle(node)).toBe('Grzyby leśne — Blog | Atlas')
expect(node.querySelector('.seo-serp__notes')?.textContent).toContain('%title% — Blog | %site%')
})
it('without a type template the general template the context names applies', () => {
const node = preview({ titleTemplate: '%title% | %site%', defaultTitle: 'Grzyby' })
expect(shownTitle(node)).toBe('Grzyby | Atlas')
})
it('an empty template leaves defaultTitle', () => {
expect(shownTitle(preview({ titleTemplate: '', defaultTitle: 'Plain' }))).toBe('Plain')
})
it('a template that renders empty falls back to defaultTitle', () => {
expect(shownTitle(preview({ titleTemplate: '%site%', siteName: '', defaultTitle: 'Only title' }))).toBe('Only title')
})
it('an SEO title wins over every template', () => {
const node = preview({ titleTemplate: '%title% | %site%' }, { seo_title: { en: 'Own title' } })
expect(shownTitle(node)).toBe('Own title')
expect(node.querySelector('.seo-serp__notes')?.textContent ?? '').not.toContain('%title%')
})
it('renderTemplate trims the separators an empty placeholder leaves, like the Go side', () => {
expect(serp.renderTemplate('%title% | %site%', { title: 'About', site: '' })).toBe('About')
expect(serp.renderTemplate(' %title% · %site% ', { title: 'A', site: 'B' })).toBe('A · B')
expect(serp.renderTemplate('', { title: 'A', site: 'B' })).toBe('')
})
it('a missing description shows the noDescription line', () => {
const node = preview({ defaultDescription: null })
const desc = node.querySelector('.serp-card__description')
expect(desc?.classList.contains('serp-card__description--missing')).toBe(true)
expect(desc?.textContent).toBe(labels.labelsFor('en').noDescription)
})
})
describe('E3 truncation', () => {
it('cuts at the last word boundary within 580px and appends " …"', () => {
const long = 'Atlas grzybów jadalnych i trujących w polskich lasach liściastych oraz iglastych'
const cut = measure.truncate(long, measure.TITLE_FONT, measure.TITLE_MAX_PX)
expect(cut.endsWith(' …')).toBe(true)
const kept = cut.slice(0, -2)
expect(long.startsWith(kept)).toBe(true)
expect(long[kept.length]).toBe(' ')
expect(measure.textWidth(cut, measure.TITLE_FONT)).toBeLessThanOrEqual(580)
// The next word would not fit.
const next = long.indexOf(' ', kept.length + 1)
expect(measure.textWidth(long.slice(0, next) + ' …', measure.TITLE_FONT)).toBeGreaterThan(580)
})
it('a title that fits is unchanged', () => {
expect(measure.truncate('Short title', measure.TITLE_FONT, 580)).toBe('Short title')
expect(measure.truncate('x'.repeat(58), measure.TITLE_FONT, 580)).toBe('x'.repeat(58))
})
it('a single word too wide is cut inside the word', () => {
const cut = measure.truncate('x'.repeat(100), measure.TITLE_FONT, 580)
expect(cut).toBe('x'.repeat(56) + ' …')
})
it('the preview card truncates the title and the description', () => {
const node = preview({ titleTemplate: '', defaultTitle: 'word '.repeat(30).trim(), defaultDescription: 'opis '.repeat(300).trim() })
const title = shownTitle(node)
expect(title.endsWith(' …')).toBe(true)
expect(Array.from(title).length * 10).toBeLessThanOrEqual(580)
const desc = node.querySelector('.serp-card__description')?.textContent ?? ''
expect(Array.from(desc).length * 10).toBeLessThanOrEqual(920)
})
})
describe('E3 keyphrase stem matching (Q-4)', () => {
it('grzyby matches grzybów', () => {
expect(serp.phraseIn('grzyby', 'Atlas grzybów')).toBe(true)
expect(serp.phraseIn('grzybów', 'Grzyby jadalne')).toBe(true)
})
it('grzyb does not match grzbiet', () => {
expect(serp.phraseIn('grzyb', 'grzbiet górski')).toBe(false)
expect(serp.wordsMatch('grzyb', 'grzbiet')).toBe(false)
})
it('diacritics and case are ignored', () => {
expect(serp.phraseIn('ŁÓDŹ', 'lodz i okolice')).toBe(true)
expect(serp.phraseIn('zazolc gesla', 'Zażółć GĘŚLĄ jaźń')).toBe(true)
})
it('URL hyphens equal spaces', () => {
expect(serp.phraseIn('grzyby lesne', '/atlas/grzyby-lesne')).toBe(true)
expect(serp.phraseIn('grzyby-leśne', 'grzyby leśne w Polsce')).toBe(true)
})
it('the phrase must appear in order', () => {
expect(serp.phraseIn('lesne grzyby', 'grzyby leśne')).toBe(false)
expect(serp.phraseIn('', 'anything')).toBe(false)
})
it('short words match only when equal', () => {
expect(serp.wordsMatch('kot', 'koty')).toBe(false)
expect(serp.wordsMatch('kot', 'kot')).toBe(true)
})
})
describe('E3 host protocol', () => {
const answer = (payload: unknown): unknown => {
const p = payload as { op: string; locale?: string }
if (p.op === 'context') {
return { op: 'context', host: 'example.test', locales: [contextLocale()] }
}
return { op: 'duplicates', locale: p.locale, title: [], description: [] }
}
it('loads its context from a host that appends the element before wiring it (CR-04)', async () => {
const host = mountWidget('golem15-seo-serp', { order: 'append-first', attrs: { 'ui-locale': 'en' }, answer })
await settle()
expect(host.posted[0]).toEqual({ op: 'context' })
expect(host.node.hasAttribute('aria-busy')).toBe(false)
expect(shownTitle(host.node)).toBe('Post')
expect(host.posted.filter((p) => (p as { op: string }).op === 'context')).toHaveLength(1)
})
it('loads its context from a host that wires the element first, once', async () => {
const host = mountWidget('golem15-seo-serp', { order: 'assign-first', attrs: { 'ui-locale': 'en' }, answer })
await settle()
expect(host.emitted.filter((p) => (p as { op: string }).op === 'context')).toHaveLength(1)
expect(shownTitle(host.node)).toBe('Post')
})
it('retries an action the host dropped once', async () => {
const host = mountWidget('golem15-seo-serp', { order: 'assign-first', attrs: { 'ui-locale': 'en' }, answer })
host.drop(1)
await settle()
expect(host.emitted.slice(0, 2)).toEqual([{ op: 'context' }, { op: 'context' }])
expect(host.posted[0]).toEqual({ op: 'context' })
expect(shownTitle(host.node)).toBe('Post')
})
it('shows the context error when the host never takes the action', async () => {
const host = mountWidget('golem15-seo-serp', { wired: false, attrs: { 'ui-locale': 'en' }, answer })
await settle()
expect(host.emitted).toEqual([{ op: 'context' }, { op: 'context' }])
expect(host.node.hasAttribute('aria-busy')).toBe(false)
expect(host.node.querySelector('.seo-serp__error')?.textContent).toBe(labels.labelsFor('en').contextFailed)
})
it('a duplicate check asked while a request runs is sent after busy clears', async () => {
const host = mountWidget('golem15-seo-serp', { attrs: { 'ui-locale': 'en', 'fill-values': JSON.stringify({ seo_title: { en: 'First' } }) }, answer })
await settle()
const before = host.posted.length
expect(host.posted[before - 1]).toMatchObject({ op: 'duplicates', title: 'First' })
host.node.setAttribute('busy', '')
host.node.setAttribute('fill-values', JSON.stringify({ seo_title: { en: 'Second' } }))
await new Promise((resolve) => setTimeout(resolve, 900))
expect(host.posted.length).toBe(before)
host.node.removeAttribute('busy')
await settle()
expect(host.posted[host.posted.length - 1]).toMatchObject({ op: 'duplicates', title: 'Second' })
})
it('state=error unblocks the next request', async () => {
let failNext = false
const host = mountWidget('golem15-seo-serp', {
attrs: { 'ui-locale': 'en', 'fill-values': JSON.stringify({ seo_title: { en: 'First' } }) },
answer: (payload) => {
if (failNext && (payload as { op: string }).op === 'duplicates') {
failNext = false
throw new HostFailure('duplicates')
}
return answer(payload)
},
})
await settle()
failNext = true
host.node.setAttribute('fill-values', JSON.stringify({ seo_title: { en: 'Second' } }))
await new Promise((resolve) => setTimeout(resolve, 900))
await settle()
expect(host.node.getAttribute('state')).toBe('error')
expect(host.posted[host.posted.length - 1]).toMatchObject({ op: 'duplicates', title: 'Second' })
expect(host.node.querySelector('.seo-serp__checking')).toBeNull()
host.node.setAttribute('fill-values', JSON.stringify({ seo_title: { en: 'Third' } }))
await new Promise((resolve) => setTimeout(resolve, 900))
await settle()
expect(host.posted[host.posted.length - 1]).toMatchObject({ op: 'duplicates', title: 'Third' })
expect(host.node.hasAttribute('state')).toBe(false)
})
it('a request in flight when the element is disconnected does not block it after reconnecting', async () => {
const host = mountWidget('golem15-seo-serp', { attrs: { 'ui-locale': 'en' }, answer })
await Promise.resolve()
expect(host.busy()).toBe(true)
host.node.remove()
await settle()
expect(host.node.hasAttribute('aria-busy')).toBe(true)
document.body.append(host.node)
await settle()
expect(host.posted.filter((p) => (p as { op: string }).op === 'context')).toHaveLength(2)
expect(shownTitle(host.node)).toBe('Post')
})
}, 5000)