Files
summercms/admin/tests/form/WidgetField.test.ts
Jakub Zych 11c4e5466b test(10.1-04): bring the SPA extension point under Vitest
- WidgetField: skeleton and aria-busy, the 5000 ms whenDefined timeout,
  script failure, attributes only, fill-values and locale sync, one POST
  while busy, fill-key-only patching, danger toasts, unmount
- PartialHost and partialNodes: exhaustive tag, attribute and URL
  allowlist, depth and node caps, skeleton sizes, empty, failure,
  busy refetch and ?id= only with a record
- PartialField, ListToolbar, ListView, registry, formState, FormField and
  FormView: group labels, registered toolbar actions, header refetch
  rules, form context provision and asset loading
2026-09-29 02:59:13 +02:00

361 lines
15 KiB
TypeScript

// WidgetField (D-04, D-05, D-07, D-08; UI-SPEC S3, S5; T-10.1-17, T-10.1-18):
// the plugin custom element is created once its controller script is in and
// its tag is defined, gets attributes only, and asks for its action with a
// bubbling summer-action event the SPA posts. Mounted on its own with the
// form context FormView would provide; HTTP goes through the fetch mock.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { ref } from 'vue'
import WidgetField from '../../src/components/form/fields/WidgetField.vue'
import ExtensionFailure from '../../src/components/ui/ExtensionFailure.vue'
import {
FORM_ASSETS,
FORM_LOCALE,
FORM_PATCH,
FORM_VALUES,
WIDGET_EVENT,
WIDGET_TIMEOUT,
} from '../../src/components/form/formContext'
import type { AdminRecord, FormField } from '../../src/api/types'
import { toasts } from '../../src/state/useToasts'
import { clone, extensionFormSchemaFixture } from '../fixtures/typed'
import { API, mockApi, requestsTo, resetState, type Reply, type Route } from '../helpers'
const ACTION = `${API}/acme/demo/widgets/widgets/lookup`
const SOURCE = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
const WIDGET_FAILED = 'Nie udało się wczytać tej kontrolki. Odśwież stronę, a jeśli błąd wróci, sprawdź zasoby wtyczki.'
const ACTION_FAILED = 'Nie udało się wykonać akcji. Spróbuj ponownie.'
class AcmeDemoLookup extends HTMLElement {}
if (!customElements.get('acme-demo-lookup')) {
customElements.define('acme-demo-lookup', AcmeDemoLookup)
}
/** The fixture's lookup widget, filling name and color. */
function lookupField(change: Partial<FormField> = {}): FormField {
const field = clone(extensionFormSchemaFixture).data.fields.find((item) => item.name === 'lookup')
if (!field) {
throw new Error('fixture lookup field missing')
}
return { ...field, fill: ['name', 'color'], ...change }
}
interface Options {
field?: FormField
recordId?: number | null
values?: AdminRecord
locale?: string
assets?: () => Promise<string[]>
source?: typeof SOURCE | null
routes?: Record<string, Route>
}
function mountWidget(options: Options = {}) {
const values = ref<AdminRecord>(options.values ?? { name: 'Blue widget', color: '#0000ff', weight: 3 })
const locale = ref(options.locale ?? 'en')
const patch = vi.fn((name: string, value: unknown) => {
values.value = { ...values.value, [name]: value }
})
const calls = mockApi(options.routes ?? {})
const wrapper = mount(WidgetField, {
props: {
field: options.field ?? lookupField(),
modelValue: undefined,
controlId: 'f-lookup',
describedBy: 'f-lookup-comment',
source: options.source === undefined ? SOURCE : options.source,
recordId: options.recordId === undefined ? 7 : options.recordId,
},
global: {
provide: {
[FORM_VALUES as symbol]: values,
[FORM_PATCH as symbol]: patch,
[FORM_LOCALE as symbol]: locale,
[FORM_ASSETS as symbol]: options.assets ?? (() => Promise.resolve([])),
},
},
})
return { wrapper, values, locale, patch, calls }
}
function element(wrapper: VueWrapper): HTMLElement {
const found = wrapper.find('[data-widget-host] > div > *').element
if (!(found instanceof HTMLElement)) {
throw new Error('widget element not mounted')
}
return found
}
function act(target: HTMLElement): void {
target.dispatchEvent(new CustomEvent(WIDGET_EVENT, { bubbles: true, composed: true }))
}
/** A route whose reply the test releases. */
function deferred(): { route: () => Promise<Reply>; release: (reply: Reply) => void } {
let release: (reply: Reply) => void = () => undefined
const pending = new Promise<Reply>((resolve) => {
release = resolve
})
return { route: () => pending, release }
}
beforeEach(() => {
resetState()
})
afterEach(() => {
vi.useRealTimers()
document.body.innerHTML = ''
})
describe('loading and failure states', () => {
it('shows a skeleton and aria-busy while the controller script loads', async () => {
const { wrapper } = mountWidget({ assets: () => new Promise<string[]>(() => undefined) })
await flushPromises()
const host = wrapper.find('[data-widget-host]')
expect(host.attributes('role')).toBe('group')
expect(host.attributes('id')).toBe('f-lookup')
expect(host.attributes('aria-busy')).toBe('true')
expect(host.attributes('aria-labelledby')).toBe('f-lookup-label')
expect(host.attributes('aria-describedby')).toBe('f-lookup-comment')
const skeleton = wrapper.find('[data-widget-skeleton]')
expect(skeleton.attributes('aria-hidden')).toBe('true')
expect(skeleton.classes()).toEqual(expect.arrayContaining(['h-[42px]', 'w-[160px]', 'rounded-control', 'bg-skel']))
expect(wrapper.find('acme-demo-lookup').exists()).toBe(false)
})
it('waits for the tag to be defined and gives up after 5000 ms', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const { wrapper } = mountWidget({ field: lookupField({ widget: 'acme-demo-never' }) })
await flushPromises()
expect(WIDGET_TIMEOUT).toBe(5000)
await vi.advanceTimersByTimeAsync(WIDGET_TIMEOUT - 1)
expect(wrapper.find('[data-widget-host]').attributes('aria-busy')).toBe('true')
expect(wrapper.find('[data-widget-failed]').exists()).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await flushPromises()
const box = wrapper.find('[data-widget-failed]')
expect(box.attributes('role')).toBe('alert')
expect(box.attributes('id')).toBe('f-lookup')
expect(box.attributes('aria-describedby')).toBe('f-lookup-comment')
expect(box.text()).toBe(WIDGET_FAILED)
expect(wrapper.findComponent(ExtensionFailure).exists()).toBe(true)
expect(wrapper.find('[data-widget-host]').exists()).toBe(false)
})
it('mounts an element defined while it waits (customElements.whenDefined)', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const { wrapper } = mountWidget({ field: lookupField({ widget: 'acme-demo-late' }) })
await flushPromises()
await vi.advanceTimersByTimeAsync(1000)
customElements.define('acme-demo-late', class extends HTMLElement {})
await flushPromises()
expect(wrapper.find('acme-demo-late').exists()).toBe(true)
expect(wrapper.find('[data-widget-host]').attributes('aria-busy')).toBeUndefined()
// The timeout was cancelled: nothing fails later.
await vi.advanceTimersByTimeAsync(WIDGET_TIMEOUT)
expect(wrapper.find('[data-widget-failed]').exists()).toBe(false)
})
it('fails at once when the script failed and the tag is still undefined', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const { wrapper } = mountWidget({
field: lookupField({ widget: 'acme-demo-broken' }),
assets: () => Promise.resolve(['/admin-test/assets/acme/demo/js/broken.js']),
})
await flushPromises()
expect(wrapper.find('[data-widget-failed]').attributes('role')).toBe('alert')
expect(vi.getTimerCount()).toBe(0)
})
it('still mounts a tag another script already defined when one script failed', async () => {
const { wrapper } = mountWidget({ assets: () => Promise.resolve(['/admin-test/assets/acme/demo/js/other.js']) })
await flushPromises()
expect(wrapper.find('acme-demo-lookup').exists()).toBe(true)
})
it('fails without a tag or without a controller', async () => {
for (const options of [{ field: lookupField({ widget: '' }) }, { source: null }] as Options[]) {
const { wrapper } = mountWidget(options)
await flushPromises()
expect(wrapper.find('[data-widget-failed]').exists()).toBe(true)
wrapper.unmount()
}
})
it('has no aria-labelledby without a label', async () => {
const { wrapper } = mountWidget({ field: lookupField({ label: undefined }) })
await flushPromises()
expect(wrapper.find('[data-widget-host]').attributes('aria-labelledby')).toBeUndefined()
expect(element(wrapper).getAttribute('label')).toBe('Look up')
})
})
describe('element attributes (D-08)', () => {
it('sets attributes only, never properties or functions', async () => {
const { wrapper } = mountWidget()
await flushPromises()
const target = element(wrapper)
expect(target.tagName.toLowerCase()).toBe('acme-demo-lookup')
expect(target.getAttributeNames().sort()).toEqual(['busy-label', 'field-name', 'fill-values', 'label', 'locale', 'record-id'])
expect(target.getAttribute('record-id')).toBe('7')
expect(target.getAttribute('field-name')).toBe('lookup')
expect(target.getAttribute('locale')).toBe('en')
expect(target.getAttribute('fill-values')).toBe('{"name":"Blue widget","color":"#0000ff"}')
expect(target.getAttribute('label')).toBe('Look up')
expect(target.getAttribute('busy-label')).toBe('Wczytywanie…')
expect(Object.keys(target)).toEqual([])
expect(wrapper.find('[data-widget-skeleton]').exists()).toBe(false)
})
it('follows form values and the locale, and leaves out absent fill values', async () => {
const { wrapper, values, locale } = mountWidget({ values: { name: 'Blue widget' } })
await flushPromises()
const target = element(wrapper)
expect(target.getAttribute('fill-values')).toBe('{"name":"Blue widget"}')
values.value = { name: 'Red widget', color: '#ff0000', weight: 9 }
await flushPromises()
expect(target.getAttribute('fill-values')).toBe('{"name":"Red widget","color":"#ff0000"}')
locale.value = 'pl'
await flushPromises()
expect(target.getAttribute('locale')).toBe('pl')
})
it('labels the element from actionLabel, then label, then the field name', async () => {
const { wrapper } = mountWidget({ field: lookupField({ actionLabel: undefined, label: undefined }) })
await flushPromises()
expect(element(wrapper).getAttribute('label')).toBe('lookup')
})
it('has an empty record id on create', async () => {
const { wrapper } = mountWidget({ recordId: null, values: {} })
await flushPromises()
expect(element(wrapper).getAttribute('record-id')).toBe('')
expect(element(wrapper).getAttribute('fill-values')).toBe('{}')
})
})
describe('summer-action (D-05, D-07)', () => {
it('posts {record_id, values} with the CSRF header, once while busy', async () => {
const action = deferred()
const { wrapper, calls } = mountWidget({ routes: { [`POST ${ACTION}`]: action.route } })
await flushPromises()
const target = element(wrapper)
act(target)
await flushPromises()
expect(target.hasAttribute('busy')).toBe(true)
act(target)
act(target)
await flushPromises()
const posts = requestsTo(calls, 'POST', ACTION)
expect(posts).toHaveLength(1)
expect(posts[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(await posts[0]!.json()).toEqual({ record_id: 7, values: { name: 'Blue widget', color: '#0000ff' } })
action.release({ body: { data: { message: '', fill: {} }, meta: {} } })
await flushPromises()
expect(target.hasAttribute('busy')).toBe(false)
act(target)
await flushPromises()
expect(requestsTo(calls, 'POST', ACTION)).toHaveLength(2)
})
it('patches only the declared fill keys the server returned and toasts the message', async () => {
const { wrapper, patch } = mountWidget({
routes: { [`POST ${ACTION}`]: { body: { data: { message: 'Name filled in', fill: { name: 'Catalogue widget', weight: 99 } }, meta: {} } } },
})
await flushPromises()
act(element(wrapper))
await flushPromises()
expect(patch.mock.calls).toEqual([['name', 'Catalogue widget']])
expect(toasts.value[0]).toMatchObject({ text: 'Name filled in', tone: 'success' })
expect(element(wrapper).getAttribute('fill-values')).toBe('{"name":"Catalogue widget","color":"#0000ff"}')
expect(element(wrapper).hasAttribute('state')).toBe(false)
})
it('does not toast an empty message', async () => {
const { wrapper, patch } = mountWidget({ routes: { [`POST ${ACTION}`]: { body: { data: { message: '', fill: { color: null } }, meta: {} } } } })
await flushPromises()
act(element(wrapper))
await flushPromises()
expect(patch.mock.calls).toEqual([['color', null]])
expect(toasts.value).toHaveLength(0)
})
it('sends no record_id on create', async () => {
const { wrapper, calls } = mountWidget({
recordId: null,
values: {},
routes: { [`POST ${ACTION}`]: { body: { data: { message: 'Done', fill: {} }, meta: {} } } },
})
await flushPromises()
act(element(wrapper))
await flushPromises()
expect(await requestsTo(calls, 'POST', ACTION)[0]!.json()).toEqual({ values: {} })
})
it.each([
['the server message', { status: 422, body: { error: { code: 'validation_failed', message: 'Catalogue unreachable.', details: {} } } }, 'Catalogue unreachable.'],
['the framework text without one', { status: 500, body: {} }, ACTION_FAILED],
['the framework text on a 403', { status: 403, body: { error: { code: 'forbidden', message: '', details: {} } } }, ACTION_FAILED],
])('toasts danger with %s and marks the element', async (_, reply, text) => {
const { wrapper, patch } = mountWidget({ routes: { [`POST ${ACTION}`]: reply } })
await flushPromises()
const target = element(wrapper)
act(target)
await flushPromises()
expect(toasts.value[0]).toMatchObject({ text, tone: 'danger' })
expect(target.getAttribute('state')).toBe('error')
expect(target.hasAttribute('busy')).toBe(false)
expect(patch).not.toHaveBeenCalled()
})
it('clears the error state on the next run', async () => {
let reply: Reply = { status: 500, body: {} }
const { wrapper } = mountWidget({ routes: { [`POST ${ACTION}`]: () => reply } })
await flushPromises()
const target = element(wrapper)
act(target)
await flushPromises()
expect(target.getAttribute('state')).toBe('error')
reply = { body: { data: { message: 'ok', fill: {} }, meta: {} } }
act(target)
await flushPromises()
expect(target.hasAttribute('state')).toBe(false)
})
it('toasts the framework text when the request cannot be sent', async () => {
const { wrapper } = mountWidget()
await flushPromises()
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network down'))
const target = element(wrapper)
act(target)
await flushPromises()
expect(toasts.value[0]).toMatchObject({ text: ACTION_FAILED, tone: 'danger' })
expect(target.getAttribute('state')).toBe('error')
expect(target.hasAttribute('busy')).toBe(false)
})
it('stops listening once unmounted', async () => {
const { wrapper, calls } = mountWidget({ routes: { [`POST ${ACTION}`]: (): Reply => ({ body: { data: { message: 'x', fill: {} }, meta: {} } }) } })
await flushPromises()
const target = element(wrapper)
wrapper.unmount()
act(target)
await flushPromises()
expect(requestsTo(calls, 'POST', ACTION)).toHaveLength(0)
})
it('never mounts an element after an unmount during loading', async () => {
let release: (failed: string[]) => void = () => undefined
const { wrapper } = mountWidget({ assets: () => new Promise<string[]>((resolve) => (release = resolve)) })
await flushPromises()
const host = wrapper.find('[data-widget-host] > div').element
wrapper.unmount()
release([])
await flushPromises()
expect(host.children).toHaveLength(0)
})
})