Files
sm-seo-plugin/tests/elements/robotstester.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

145 lines
5.4 KiB
TypeScript

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')
})
})