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.
115 lines
3.6 KiB
TypeScript
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))
|
|
}
|
|
}
|