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:
2
assets/dist/seo.js
vendored
2
assets/dist/seo.js
vendored
File diff suppressed because one or more lines are too long
101
assets/src/elements/actions.ts
Normal file
101
assets/src/elements/actions.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { emit } from './labels'
|
||||
|
||||
/**
|
||||
* Sends a widget element's summer-action payloads one at a time.
|
||||
*
|
||||
* The admin's widget host takes an action by setting the element's `busy`
|
||||
* attribute synchronously, inside the summer-action listener, and removes
|
||||
* it when the request settles (after it dispatched summer-result, or set
|
||||
* `state=error`). A payload sent while a request runs is queued (one at
|
||||
* most, the newest wins) and sent when `busy` goes.
|
||||
*
|
||||
* The queue does not trust that an emitted action was taken: when `busy`
|
||||
* is still absent after the event returns, the host dropped it (for
|
||||
* example an action sent from connectedCallback before the host wired the
|
||||
* element). The payload is then retried once on the next task; a second
|
||||
* drop is reported through onDropped, so the element shows an error rather
|
||||
* than waiting forever.
|
||||
*/
|
||||
export class ActionQueue<P> {
|
||||
private inflight: P | null = null
|
||||
private queued: P | null = null
|
||||
private retry: P | null = null
|
||||
private retried = false
|
||||
private timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
constructor(
|
||||
private readonly host: HTMLElement,
|
||||
private readonly onDropped: (payload: P) => void,
|
||||
) {}
|
||||
|
||||
/** The payload the host is working on, null when none. */
|
||||
get current(): P | null {
|
||||
return this.inflight
|
||||
}
|
||||
|
||||
/** Sends payload, or queues it while a request runs. */
|
||||
request(payload: P): void {
|
||||
if (this.host.hasAttribute('busy') || this.inflight !== null) {
|
||||
this.queued = payload
|
||||
return
|
||||
}
|
||||
this.send(payload)
|
||||
}
|
||||
|
||||
/** Clears the request that just settled (summer-result or state=error) and sends what waits. */
|
||||
settle(): P | null {
|
||||
const done = this.inflight
|
||||
this.inflight = null
|
||||
this.flush()
|
||||
return done
|
||||
}
|
||||
|
||||
/** Sends the waiting payload when the host is free. */
|
||||
flush(): void {
|
||||
if (this.host.hasAttribute('busy') || this.inflight !== null) {
|
||||
return
|
||||
}
|
||||
const next = this.retry ?? this.queued
|
||||
if (next === null) {
|
||||
return
|
||||
}
|
||||
if (this.retry !== null) {
|
||||
this.retry = null
|
||||
} else {
|
||||
this.queued = null
|
||||
}
|
||||
this.send(next)
|
||||
}
|
||||
|
||||
/** Forgets the request in flight and any pending retry (the element left the page). */
|
||||
reset(): void {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
this.inflight = null
|
||||
this.retry = null
|
||||
this.retried = false
|
||||
}
|
||||
|
||||
private send(payload: P): void {
|
||||
this.inflight = payload
|
||||
emit(this.host, 'summer-action', { payload })
|
||||
if (this.host.hasAttribute('busy') || this.inflight !== payload) {
|
||||
// Taken (or already answered synchronously).
|
||||
this.retried = false
|
||||
return
|
||||
}
|
||||
this.inflight = null
|
||||
if (this.retried) {
|
||||
this.retried = false
|
||||
this.onDropped(payload)
|
||||
return
|
||||
}
|
||||
this.retried = true
|
||||
this.retry = payload
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined
|
||||
this.flush()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,7 @@ export interface Labels {
|
||||
unsaved: string
|
||||
stagingNote: string
|
||||
urlRequired: string
|
||||
testFailed: string
|
||||
// E6
|
||||
exportHeading: string
|
||||
importHeading: string
|
||||
@@ -217,6 +218,7 @@ const en: Labels = {
|
||||
unsaved: 'Tests the text in the editor and the AI bot switches, including unsaved changes.',
|
||||
stagingNote: 'On this environment every address is blocked. The result shows the production behaviour.',
|
||||
urlRequired: 'Enter an address starting with /.',
|
||||
testFailed: 'The test could not be run. Try again.',
|
||||
exportHeading: 'Export',
|
||||
importHeading: 'Import',
|
||||
dataset: 'Data',
|
||||
@@ -343,6 +345,7 @@ const pl: Labels = {
|
||||
unsaved: 'Sprawdza tekst w edytorze i przełączniki botów AI, także niezapisane zmiany.',
|
||||
stagingNote: 'W tym środowisku każdy adres jest zablokowany. Wynik pokazuje zachowanie na produkcji.',
|
||||
urlRequired: 'Wpisz adres zaczynający się od /.',
|
||||
testFailed: 'Nie udało się uruchomić testu. Spróbuj ponownie.',
|
||||
exportHeading: 'Eksport',
|
||||
importHeading: 'Import',
|
||||
dataset: 'Dane',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { el, emit, fmt, icon, jsonAttr, labelsFor, uid, uiLocaleOf, type Labels } from './labels'
|
||||
import { ActionQueue } from './actions'
|
||||
import { el, fmt, icon, jsonAttr, labelsFor, uid, uiLocaleOf, type Labels } from './labels'
|
||||
|
||||
export const ROBOTSTESTER_TAG = 'golem15-seo-robotstester'
|
||||
|
||||
@@ -49,8 +50,7 @@ export class SeoRobotsTesterElement extends HTMLElement {
|
||||
private context: ContextData | null = null
|
||||
private result: TestData | null = null
|
||||
private error = ''
|
||||
private inflight: Payload | null = null
|
||||
private queued: Payload | null = null
|
||||
private readonly actions = new ActionQueue<Payload>(this, (payload) => this.failed(payload))
|
||||
private connected = false
|
||||
private input: HTMLInputElement | null = null
|
||||
private select: HTMLSelectElement | null = null
|
||||
@@ -62,7 +62,7 @@ export class SeoRobotsTesterElement extends HTMLElement {
|
||||
private readonly onResult = (ev: Event): void => {
|
||||
const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined
|
||||
const data = detail?.data as ContextData | TestData | undefined
|
||||
this.inflight = null
|
||||
this.actions.settle()
|
||||
if (data && data.op === 'context') {
|
||||
this.context = data
|
||||
this.build(true)
|
||||
@@ -71,19 +71,25 @@ export class SeoRobotsTesterElement extends HTMLElement {
|
||||
this.error = ''
|
||||
this.renderResult()
|
||||
}
|
||||
this.flush()
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.connected = true
|
||||
this.addEventListener('summer-result', this.onResult)
|
||||
this.build(false)
|
||||
this.request({ op: 'context' })
|
||||
// Deferred: the host may wire the element only after connecting it.
|
||||
queueMicrotask(() => {
|
||||
if (this.connected && !this.context) {
|
||||
this.request({ op: 'context' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.connected = false
|
||||
this.removeEventListener('summer-result', this.onResult)
|
||||
// A result that arrives while disconnected is lost; start over on reconnect.
|
||||
this.actions.reset()
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
|
||||
@@ -91,10 +97,10 @@ export class SeoRobotsTesterElement extends HTMLElement {
|
||||
return
|
||||
}
|
||||
if (name === 'busy' && value === null) {
|
||||
this.flush()
|
||||
this.actions.flush()
|
||||
}
|
||||
if (name === 'state' && value === 'error') {
|
||||
this.inflight = null
|
||||
this.actions.settle()
|
||||
}
|
||||
if (name === 'ui-locale') {
|
||||
this.build(true)
|
||||
@@ -108,21 +114,16 @@ export class SeoRobotsTesterElement extends HTMLElement {
|
||||
}
|
||||
|
||||
private request(payload: Payload): void {
|
||||
if (this.hasAttribute('busy') || this.inflight) {
|
||||
this.queued = payload
|
||||
return
|
||||
}
|
||||
this.inflight = payload
|
||||
emit(this, 'summer-action', { payload })
|
||||
this.actions.request(payload)
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
if (this.hasAttribute('busy') || this.inflight || !this.queued) {
|
||||
return
|
||||
/** A request the host never took: the tester keeps the default crawlers, a test says it failed. */
|
||||
private failed(payload: Payload): void {
|
||||
if (payload.op === 'test') {
|
||||
this.error = this.labels().testFailed
|
||||
this.result = null
|
||||
this.renderResult()
|
||||
}
|
||||
const next = this.queued
|
||||
this.queued = null
|
||||
this.request(next)
|
||||
}
|
||||
|
||||
private build(force: boolean): void {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { el, emit, fmt, icon, jsonAttr, labelsFor, plural, uiLocaleOf, type Labels } from './labels'
|
||||
import { ActionQueue } from './actions'
|
||||
import { el, fmt, icon, jsonAttr, labelsFor, plural, uiLocaleOf, type Labels } from './labels'
|
||||
import { DESCRIPTION_FONT, DESCRIPTION_MAX_PX, DESCRIPTION_MOBILE_MAX_PX, TITLE_FONT, TITLE_MAX_PX, truncate } from './measure'
|
||||
|
||||
export const SERP_TAG = 'golem15-seo-serp'
|
||||
@@ -135,8 +136,7 @@ export class SeoSerpElement extends HTMLElement {
|
||||
private contextFailed = false
|
||||
private device: 'desktop' | 'mobile' = 'desktop'
|
||||
private shownLocale = ''
|
||||
private inflight: Payload | null = null
|
||||
private queued: Payload | null = null
|
||||
private readonly actions = new ActionQueue<Payload>(this, (payload) => this.failed(payload))
|
||||
private duplicates = new Map<string, DuplicatesData>()
|
||||
private checking = false
|
||||
private timer: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -146,7 +146,7 @@ export class SeoSerpElement extends HTMLElement {
|
||||
private readonly onResult = (ev: Event): void => {
|
||||
const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined
|
||||
const data = detail?.data as ContextData | DuplicatesData | undefined
|
||||
this.inflight = null
|
||||
this.actions.settle()
|
||||
if (data && data.op === 'context') {
|
||||
this.context = data
|
||||
this.contextFailed = false
|
||||
@@ -157,7 +157,6 @@ export class SeoSerpElement extends HTMLElement {
|
||||
this.checking = false
|
||||
this.render()
|
||||
}
|
||||
this.flush()
|
||||
}
|
||||
|
||||
private readonly onLocale = (ev: Event): void => {
|
||||
@@ -174,7 +173,17 @@ export class SeoSerpElement extends HTMLElement {
|
||||
this.addEventListener('summer-result', this.onResult)
|
||||
window.addEventListener('summer:ml-locale', this.onLocale)
|
||||
this.render()
|
||||
this.request({ op: 'context' })
|
||||
// Deferred: the host may wire the element only after connecting it.
|
||||
queueMicrotask(() => {
|
||||
if (!this.connected) {
|
||||
return
|
||||
}
|
||||
if (this.context) {
|
||||
this.scheduleDuplicates(true)
|
||||
} else {
|
||||
this.request({ op: 'context' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
@@ -182,6 +191,10 @@ export class SeoSerpElement extends HTMLElement {
|
||||
this.removeEventListener('summer-result', this.onResult)
|
||||
window.removeEventListener('summer:ml-locale', this.onLocale)
|
||||
clearTimeout(this.timer)
|
||||
// A result that arrives while disconnected is lost; start over on reconnect.
|
||||
this.actions.reset()
|
||||
this.checking = false
|
||||
this.lastChecked = ''
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
|
||||
@@ -189,16 +202,17 @@ export class SeoSerpElement extends HTMLElement {
|
||||
return
|
||||
}
|
||||
if (name === 'busy' && value === null) {
|
||||
this.flush()
|
||||
this.actions.flush()
|
||||
return
|
||||
}
|
||||
if (name === 'state' && value === 'error') {
|
||||
if (this.inflight?.op === 'context') {
|
||||
this.contextFailed = true
|
||||
const failed = this.actions.settle()
|
||||
if (failed) {
|
||||
this.failed(failed)
|
||||
} else {
|
||||
this.checking = false
|
||||
this.render()
|
||||
}
|
||||
this.checking = false
|
||||
this.inflight = null
|
||||
this.render()
|
||||
return
|
||||
}
|
||||
if (name === 'fill-values') {
|
||||
@@ -215,21 +229,18 @@ export class SeoSerpElement extends HTMLElement {
|
||||
|
||||
/** Sends payload, or queues it (one at most) while a request runs. */
|
||||
private request(payload: Payload): void {
|
||||
if (this.hasAttribute('busy') || this.inflight) {
|
||||
this.queued = payload
|
||||
return
|
||||
}
|
||||
this.inflight = payload
|
||||
emit(this, 'summer-action', { payload })
|
||||
this.actions.request(payload)
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
if (this.hasAttribute('busy') || this.inflight || !this.queued) {
|
||||
return
|
||||
/** A request the host refused (state=error) or never took. */
|
||||
private failed(payload: Payload): void {
|
||||
if (payload.op === 'context') {
|
||||
this.contextFailed = true
|
||||
} else {
|
||||
this.lastChecked = ''
|
||||
}
|
||||
const next = this.queued
|
||||
this.queued = null
|
||||
this.request(next)
|
||||
this.checking = false
|
||||
this.render()
|
||||
}
|
||||
|
||||
private fill(): Record<string, unknown> {
|
||||
|
||||
114
tests/elements/host.ts
Normal file
114
tests/elements/host.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
144
tests/elements/robotstester.test.ts
Normal file
144
tests/elements/robotstester.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user