Files
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

115 lines
3.6 KiB
TypeScript

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