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.
This commit is contained in:
2026-10-27 15:24:28 +01:00
parent 2d81c261b5
commit 3ecbd1f711
8 changed files with 529 additions and 52 deletions

114
tests/elements/host.ts Normal file
View File

@@ -0,0 +1,114 @@
// A fake of the admin's widget host (summercms.go
// admin/src/components/form/fields/WidgetField.vue), so element tests drive
// the real host contract instead of dispatching summer-result by hand:
//
// - the host listens for summer-action on the element and ignores it
// while it has no element reference or is already busy;
// - it sets `busy` and clears `state` synchronously, inside the listener;
// - the request settles asynchronously: on success it dispatches
// summer-result with {data, fill, message}, on failure it sets
// state=error; then it removes `busy`.
//
// `order` reproduces how the host wires the element: 'append-first' is the
// v0.5.1 host, which appends the element (running connectedCallback) before
// it keeps the reference, so an action sent from connectedCallback is
// dropped; 'assign-first' keeps the reference before appending.
export type Answer = (payload: unknown) => unknown
export interface FakeHost {
node: HTMLElement
/** Every summer-action payload the element emitted. */
emitted: unknown[]
/** The payloads the host took and posted. */
posted: unknown[]
/** Makes the host ignore the next n actions (as a host without a reference does). */
drop(n: number): void
/** Whether a request is running. */
busy(): boolean
}
/** A reason the host's request fails with (it sets state=error). */
export class HostFailure extends Error {}
export interface MountOptions {
order?: 'append-first' | 'assign-first'
attrs?: Record<string, string>
/** Answers a posted payload with the response data; throw HostFailure to fail it. */
answer: Answer
/** When false the host never keeps a reference (every action is dropped). */
wired?: boolean
}
export function mountWidget(tag: string, options: MountOptions): FakeHost {
const node = document.createElement(tag)
for (const [name, value] of Object.entries(options.attrs ?? {})) {
node.setAttribute(name, value)
}
let element: HTMLElement | null = null
let busy = false
let dropping = 0
const emitted: unknown[] = []
const posted: unknown[] = []
async function onAction(payload: unknown): Promise<void> {
const target = element
if (!target || busy) {
return
}
if (dropping > 0) {
dropping--
return
}
busy = true
target.setAttribute('busy', '')
target.removeAttribute('state')
posted.push(payload)
try {
await Promise.resolve()
const data = options.answer(payload)
target.setAttribute('data', JSON.stringify(data))
target.dispatchEvent(new CustomEvent('summer-result', { detail: { data, fill: {}, message: undefined } }))
} catch (err) {
if (!(err instanceof HostFailure)) {
throw err
}
target.setAttribute('state', 'error')
} finally {
busy = false
element?.removeAttribute('busy')
}
}
node.addEventListener('summer-action', (event) => {
const detail = (event as CustomEvent).detail as { payload?: unknown } | undefined
emitted.push(detail?.payload)
void onAction(detail?.payload)
})
const wired = options.wired ?? true
if (options.order === 'assign-first' && wired) {
element = node
document.body.append(node)
} else {
document.body.append(node)
if (wired) {
element = node
}
}
return {
node,
emitted,
posted,
drop: (n) => {
dropping = n
},
busy: () => busy,
}
}
/** Lets microtasks and zero-delay timers run (the host's request and the elements' retries). */
export async function settle(rounds = 5): Promise<void> {
for (let i = 0; i < rounds; i++) {
await new Promise((resolve) => setTimeout(resolve, 0))
}
}

View File

@@ -0,0 +1,144 @@
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { labelsFor } from '../../assets/src/elements/labels'
import { HostFailure, mountWidget, settle, type MountOptions } from './host'
// E5 golem15-seo-robotstester (UI-SPEC C10) through the widget host
// contract: the context request from connectedCallback, the queue behind
// `busy`, state=error, the address check and a host that drops actions.
beforeAll(async () => {
await import('../../assets/src/elements/index')
})
afterEach(() => {
document.body.replaceChildren()
})
type Payload = { op: 'context' } | { op: 'test'; url: string; agent: string }
function answer(payload: unknown): unknown {
const p = payload as Payload
if (p.op === 'context') {
return { op: 'context', agents: ['Googlebot', 'Bingbot', 'GPTBot', '*'], bots: ['GPTBot'], staging: false }
}
return { op: 'test', url: p.url, allowed: p.url !== '/admin', line: p.url === '/admin' ? 2 : 0, rule: p.url === '/admin' ? 'Disallow: /admin' : '', bot: false, agent: p.agent, staging: false }
}
function tester(options: Partial<MountOptions> = {}) {
return mountWidget('golem15-seo-robotstester', {
attrs: { 'ui-locale': 'en', label: 'Test URL', 'busy-label': 'Testing…' },
answer,
...options,
})
}
function testURL(node: HTMLElement, url: string): void {
const input = node.querySelector<HTMLInputElement>('.seo-robotstester__input')!
input.value = url
node.querySelector<HTMLButtonElement>('.seo-robotstester__button')!.click()
}
function resultLine(node: HTMLElement): string {
return node.querySelector('.seo-robotstester__result')?.textContent ?? ''
}
function agents(node: HTMLElement): string[] {
return Array.from(node.querySelectorAll('.seo-robotstester__select option')).map((o) => o.textContent ?? '')
}
describe('E5 host protocol', () => {
it('loads the crawler list from a host that appends the element before wiring it (CR-04)', async () => {
const host = tester({ order: 'append-first' })
await settle()
expect(host.posted[0]).toEqual({ op: 'context' })
expect(agents(host.node)).toEqual(['Googlebot', 'Bingbot', 'GPTBot', '*'])
})
it('a test asked while the context loads is queued and sent after busy clears', async () => {
const host = tester({ order: 'assign-first' })
await Promise.resolve()
expect(host.busy()).toBe(true)
// The button is disabled while busy; Enter in the address field still asks.
const input = host.node.querySelector<HTMLInputElement>('.seo-robotstester__input')!
input.value = '/admin'
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(host.posted).toEqual([{ op: 'context' }])
await settle()
expect(host.posted).toEqual([{ op: 'context' }, { op: 'test', url: '/admin', agent: 'Googlebot' }])
expect(resultLine(host.node)).toContain('Disallow: /admin')
})
it('the button shows the busy label while a test runs', async () => {
const host = tester()
await settle()
host.node.setAttribute('busy', '')
expect(host.node.querySelector('.seo-robotstester__button')?.textContent).toBe('Testing…')
host.node.removeAttribute('busy')
expect(host.node.querySelector('.seo-robotstester__button')?.textContent).toBe('Test URL')
})
it('state=error unblocks the next test', async () => {
let failNext = true
const host = tester({
answer: (payload) => {
if ((payload as Payload).op === 'test' && failNext) {
failNext = false
throw new HostFailure('test')
}
return answer(payload)
},
})
await settle()
testURL(host.node, '/admin')
await settle()
expect(host.node.getAttribute('state')).toBe('error')
testURL(host.node, '/blog')
await settle()
expect(host.posted.slice(-1)).toEqual([{ op: 'test', url: '/blog', agent: 'Googlebot' }])
expect(resultLine(host.node)).not.toBe('')
})
it('retries a test the host dropped once', async () => {
const host = tester()
await settle()
host.drop(1)
testURL(host.node, '/admin')
await settle()
expect(host.emitted.filter((p) => (p as Payload).op === 'test')).toHaveLength(2)
expect(host.posted.slice(-1)).toEqual([{ op: 'test', url: '/admin', agent: 'Googlebot' }])
expect(resultLine(host.node)).toContain('Disallow: /admin')
})
it('says the test failed when the host never takes it', async () => {
const host = tester({ wired: false })
await settle()
expect(agents(host.node)).toEqual(['Googlebot', 'Googlebot-Image', 'Bingbot', '*'])
testURL(host.node, '/admin')
await settle()
expect(resultLine(host.node)).toBe(labelsFor('en').testFailed)
})
it('an address without a leading slash is refused without an action', async () => {
const host = tester()
await settle()
const before = host.emitted.length
testURL(host.node, 'admin')
await settle()
expect(host.emitted.length).toBe(before)
expect(resultLine(host.node)).toBe(labelsFor('en').urlRequired)
})
it('a request in flight when the element is disconnected does not block it after reconnecting', async () => {
const host = tester()
await Promise.resolve()
expect(host.busy()).toBe(true)
host.node.remove()
await settle()
document.body.append(host.node)
await settle()
expect(agents(host.node)).toEqual(['Googlebot', 'Bingbot', 'GPTBot', '*'])
testURL(host.node, '/admin')
await settle()
expect(resultLine(host.node)).toContain('Disallow: /admin')
})
})

View File

@@ -1,4 +1,5 @@
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
@@ -23,17 +24,22 @@ afterEach(() => {
document.body.replaceChildren()
})
/** A serp element answered with one context locale. */
function preview(locale: Partial<import('../../assets/src/elements/serp').ContextLocale>, fill: Record<string, unknown> = {}): HTMLElement {
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))
const actions: unknown[] = []
node.addEventListener('summer-action', (ev) => actions.push((ev as CustomEvent).detail.payload))
document.body.append(node)
expect(actions).toEqual([{ op: 'context' }])
const loc = { locale: 'en', url: '/blog/post', h1: null, siteName: 'Atlas', titleTemplate: '', defaultTitle: 'Post', defaultDescription: null, descriptionSource: 'content', bodyWords: null, ...locale }
node.dispatchEvent(new CustomEvent('summer-result', { detail: { data: { op: 'context', host: 'example.test', locales: [loc] } } }))
node.dispatchEvent(new CustomEvent('summer-result', { detail: { data: { op: 'context', host: 'example.test', locales: [contextLocale(locale)] } } }))
return node
}
@@ -146,3 +152,100 @@ describe('E3 keyphrase stem matching (Q-4)', () => {
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)