// 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 /** 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 { 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 { for (let i = 0; i < rounds; i++) { await new Promise((resolve) => setTimeout(resolve, 0)) } }