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
This commit is contained in:
Jakub Zych
2026-09-29 02:59:13 +02:00
parent 6b0ac15086
commit 11c4e5466b
9 changed files with 1194 additions and 9 deletions

View File

@@ -3,8 +3,8 @@ import { mount } from '@vue/test-utils'
import FieldRenderer from '../../src/components/form/FieldRenderer.vue'
import FormErrorBanner from '../../src/components/form/FormErrorBanner.vue'
import FormField from '../../src/components/form/FormField.vue'
import { formField } from '../fixtures/typed'
import { resetState } from '../helpers'
import { clone, extensionFormSchemaFixture, formField } from '../fixtures/typed'
import { mockApi, resetState } from '../helpers'
beforeEach(() => {
resetState()
@@ -55,6 +55,44 @@ describe('form field', () => {
})
})
describe('widget and partial rows (Phase 10.1)', () => {
function extensionField(name: string, change: Record<string, unknown> = {}) {
const field = clone(extensionFormSchemaFixture).data.fields.find((item) => item.name === name)
if (!field) {
throw new Error(`fixture field ${name} missing`)
}
return { ...field, ...change }
}
it.each(['lookup', 'summary'])('labels the %s group with a span the group points at', (name) => {
mockApi({})
const wrapper = mount(FormField, {
props: { field: extensionField(name, { required: true }), modelValue: undefined, idPrefix: 'f', source: null },
})
const label = wrapper.find(`#f-${name}-label`)
expect(label.element.tagName).toBe('SPAN')
expect(label.classes()).toContain('font-semibold')
expect(label.find('[aria-hidden="true"]').text()).toBe('*')
expect(wrapper.find('label').exists()).toBe(false)
expect(wrapper.find(`#f-${name}`).attributes('aria-labelledby')).toBe(`f-${name}-label`)
})
it.each(['lookup', 'summary'])('shows no label row for an unlabelled %s', (name) => {
mockApi({})
const wrapper = mount(FormField, { props: { field: extensionField(name, { label: '' }), modelValue: undefined, idPrefix: 'f', source: null } })
expect(wrapper.find(`#f-${name}-label`).exists()).toBe(false)
expect(wrapper.find('label').exists()).toBe(false)
expect(wrapper.find(`#f-${name}`).attributes('aria-labelledby')).toBeUndefined()
})
it('points the widget group at its comment', () => {
mockApi({})
const wrapper = mount(FormField, { props: { field: extensionField('lookup'), modelValue: undefined, idPrefix: 'f', source: null } })
expect(wrapper.find('#f-lookup-comment').text()).toBe('Fills in the name. Save to keep it.')
expect(wrapper.find('#f-lookup').attributes('aria-describedby')).toBe('f-lookup-comment')
})
})
describe('field renderer', () => {
it('picks the control by type and forwards the shared props and updates', async () => {
const wrapper = mount(FieldRenderer, {

View File

@@ -1,10 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import FormView from '../../src/views/FormView.vue'
import { recordCrumb } from '../../src/state/useBreadcrumbs'
import { toasts } from '../../src/state/useToasts'
import { clone, formSchemaFixture, recordFixture } from '../fixtures/typed'
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
import { WIDGET_EVENT } from '../../src/components/form/formContext'
import { clone, extensionFormSchemaFixture, formSchemaFixture, recordFixture } from '../fixtures/typed'
import { API, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
const RECORD = `${BASE}/1`
@@ -147,3 +148,143 @@ describe('form view', () => {
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/5')
})
})
describe('form view extension context (Phase 10.1, D-05, D-07, D-08, D-09, D-14)', () => {
const ACTION = `${BASE}/widgets/lookup`
const SUMMARY = `${BASE}/partials/summary`
const record = { data: { id: 1, name: 'Blue widget', color: '#0000ff' }, meta: { labels: {} } }
const summary = { data: { nodes: [{ tag: 'p', children: [{ text: 'Summary' }] }] }, meta: {} }
class AcmeDemoLookup extends HTMLElement {}
if (!customElements.get('acme-demo-lookup')) {
customElements.define('acme-demo-lookup', AcmeDemoLookup)
}
/** Records appended plugin scripts; each loads when the test fires it (or at once). */
function stubHead(autoload = true): HTMLScriptElement[] {
const scripts: HTMLScriptElement[] = []
const head = document.head
const append = head.appendChild.bind(head)
vi.spyOn(head, 'appendChild').mockImplementation(<T extends Node>(node: T): T => {
if (node instanceof HTMLScriptElement) {
scripts.push(node)
if (autoload) {
queueMicrotask(() => node.dispatchEvent(new Event('load')))
}
return node
}
if (node instanceof HTMLLinkElement) {
return node
}
return append(node)
})
return scripts
}
function extensionRoutes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/form`]: { body: extensionFormSchemaFixture },
[`GET ${RECORD}`]: { body: record },
[`GET ${SUMMARY}`]: { body: summary },
...overrides,
}
}
function widget(root: Element): HTMLElement {
const element = root.querySelector('acme-demo-lookup')
if (!(element instanceof HTMLElement)) {
throw new Error('widget element not mounted')
}
return element
}
function beforeUnloadPrevented(): boolean {
const event = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(event)
return event.defaultPrevented
}
it('provides the values and the schema locale to widgets', async () => {
stubHead()
const { wrapper } = await mountApp('/acme/demo/widgets/1', extensionRoutes())
await flushPromises()
const element = widget(wrapper.element)
expect(element.getAttribute('fill-values')).toBe('{"name":"Blue widget"}')
// The UI speaks Polish; the widget gets the schema's locale.
expect(element.getAttribute('locale')).toBe('en')
await wrapper.find('#field-name').setValue('Green widget')
expect(element.getAttribute('fill-values')).toBe('{"name":"Green widget"}')
})
it('falls back to the UI locale when the schema has none', async () => {
stubHead()
const schema = clone(extensionFormSchemaFixture)
schema.data.meta.locale = ''
const { wrapper } = await mountApp('/acme/demo/widgets/1', extensionRoutes({ [`GET ${BASE}/schema/form`]: { body: schema } }))
await flushPromises()
expect(widget(wrapper.element).getAttribute('locale')).toBe('pl')
})
it('patches like an edit: the form turns dirty and the field error clears', async () => {
stubHead()
const { wrapper, calls } = await mountApp(
'/acme/demo/widgets/1',
extensionRoutes({
[`PUT ${RECORD}`]: {
status: 422,
body: { error: { code: 'validation_failed', message: 'Validation failed', details: { name: ['Name is taken.'] } } },
},
[`POST ${ACTION}`]: { body: { data: { message: 'Name filled in', fill: { name: 'Catalogue widget' } }, meta: {} } },
}),
)
await flushPromises()
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
expect(wrapper.find('#field-name-error').text()).toContain('Name is taken.')
expect(beforeUnloadPrevented()).toBe(false)
widget(wrapper.element).dispatchEvent(new CustomEvent(WIDGET_EVENT, { bubbles: true, composed: true }))
await flushPromises()
expect((wrapper.find('#field-name').element as HTMLInputElement).value).toBe('Catalogue widget')
expect(wrapper.find('#field-name-error').exists()).toBe(false)
expect(beforeUnloadPrevented()).toBe(true)
// Nothing was saved by the action itself.
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
})
it('renders widgets and partials on create', async () => {
stubHead()
const { wrapper, calls } = await mountApp('/acme/demo/widgets/create', extensionRoutes())
await flushPromises()
const element = widget(wrapper.element)
expect(element.getAttribute('record-id')).toBe('')
const [partial] = requestsTo(calls, 'GET', SUMMARY)
expect(queryOf(partial!).has('id')).toBe(false)
expect(wrapper.find('[data-partial-field] [data-partial="field"]').text()).toBe('Summary')
expect(requestsTo(calls, 'GET', RECORD)).toHaveLength(0)
})
it('loads the controller assets when the schema arrives and mounts the widget after its script', async () => {
const scripts = stubHead(false)
// A fresh URL: the loader remembers scripts across the tests of this file.
const fresh = '/admin-test/assets/acme/demo/js/form.js?v=1'
const schema = clone(extensionFormSchemaFixture)
schema.data.assets.scripts = [fresh]
let release: (reply: Reply) => void = () => undefined
const pending = new Promise<Reply>((resolve) => (release = resolve))
const { wrapper } = await mountApp('/acme/demo/widgets/1', extensionRoutes({ [`GET ${BASE}/schema/form`]: () => pending }))
expect(scripts).toHaveLength(0)
release({ body: schema })
await flushPromises()
expect(scripts.map((script) => script.getAttribute('src'))).toEqual([fresh])
expect(wrapper.find('[data-widget-host]').attributes('aria-busy')).toBe('true')
expect(wrapper.element.querySelector('acme-demo-lookup')).toBeNull()
scripts[0]!.dispatchEvent(new Event('load'))
await flushPromises()
expect(widget(wrapper.element).getAttribute('record-id')).toBe('1')
expect(wrapper.find('[data-widget-host]').attributes('aria-busy')).toBeUndefined()
})
})

View File

@@ -0,0 +1,93 @@
// PartialField (D-09; UI-SPEC S2): a `type: partial` form row renders its
// controller partial on create (no id) and update (?id=) inside a group named
// by the row's label span; without a label there is no label row and no
// aria-labelledby. Mounted through FormField, as FormGrid renders it.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import FormField from '../../src/components/form/FormField.vue'
import PartialField from '../../src/components/form/fields/PartialField.vue'
import PartialHost from '../../src/components/partial/PartialHost.vue'
import type { FormField as Field } from '../../src/api/types'
import { clone, extensionFormSchemaFixture } from '../fixtures/typed'
import { API, mockApi, queryOf, requestsTo, resetState } from '../helpers'
const SOURCE = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
const SUMMARY = `${API}/acme/demo/widgets/partials/summary`
const reply = { body: { data: { nodes: [{ tag: 'p', children: [{ text: 'Summary of the widget' }] }] }, meta: {} } }
function summaryField(change: Partial<Field> = {}): Field {
const field = clone(extensionFormSchemaFixture).data.fields.find((item) => item.name === 'summary')
if (!field) {
throw new Error('fixture summary field missing')
}
return { ...field, ...change }
}
function mountRow(field: Field, recordId: number | null, source: typeof SOURCE | null = SOURCE) {
return mount(FormField, { props: { field, modelValue: undefined, source, recordId, idPrefix: 'f' } })
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('partial form row', () => {
it('fetches without an id on create and with the record id on update', async () => {
const calls = mockApi({ [`GET ${SUMMARY}`]: reply })
mountRow(summaryField(), null)
mountRow(summaryField(), 3)
await flushPromises()
const [create, update] = requestsTo(calls, 'GET', SUMMARY)
expect(queryOf(create!).has('id')).toBe(false)
expect(queryOf(update!).get('id')).toBe('3')
})
it('is a group named by the row label span, not a <label>', async () => {
mockApi({ [`GET ${SUMMARY}`]: reply })
const wrapper = mountRow(summaryField({ comment: 'Read only', required: true }), 3)
await flushPromises()
const group = wrapper.find('[data-partial-field]')
expect(group.attributes('role')).toBe('group')
expect(group.attributes('id')).toBe('f-summary')
expect(group.attributes('aria-labelledby')).toBe('f-summary-label')
expect(group.attributes('aria-describedby')).toBe('f-summary-comment')
const label = wrapper.find('#f-summary-label')
expect(label.element.tagName).toBe('SPAN')
expect(label.text()).toBe('Summary *')
expect(label.find('[aria-hidden="true"]').text()).toBe('*')
expect(wrapper.find('label').exists()).toBe(false)
expect(group.find('[data-partial="field"]').text()).toBe('Summary of the widget')
expect(wrapper.findComponent(PartialHost).props()).toMatchObject({ variant: 'field', name: 'summary', recordId: 3 })
})
it('has no label row and no aria-labelledby without a label', async () => {
mockApi({ [`GET ${SUMMARY}`]: reply })
const wrapper = mountRow(summaryField({ label: undefined }), null)
await flushPromises()
expect(wrapper.find('#f-summary-label').exists()).toBe(false)
expect(wrapper.find('label').exists()).toBe(false)
expect(wrapper.find('[data-partial-field]').attributes('aria-labelledby')).toBeUndefined()
})
it('falls back to the field name when the path is empty', async () => {
const calls = mockApi({ [`GET ${API}/acme/demo/widgets/partials/notes`]: reply })
mountRow(summaryField({ name: 'notes', path: '' }), null)
await flushPromises()
expect(requestsTo(calls, 'GET', `${API}/acme/demo/widgets/partials/notes`)).toHaveLength(1)
})
it('shows the failure box and calls nothing without a controller (settings forms)', async () => {
const calls = mockApi({})
const wrapper = mount(PartialField, {
props: { field: summaryField(), modelValue: undefined, controlId: 'f-summary', source: null, recordId: null },
})
await flushPromises()
expect(wrapper.find('[data-partial-failed]').attributes('role')).toBe('alert')
expect(wrapper.findComponent(PartialHost).exists()).toBe(false)
expect(calls).toHaveLength(0)
})
})

View File

@@ -0,0 +1,360 @@
// 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)
})
})

View File

@@ -11,7 +11,7 @@ import {
tabDomId,
tabOf,
} from '../../src/components/form/formState'
import { formField, formSchemaFixture } from '../fixtures/typed'
import { extensionFormSchemaFixture, formField, formSchemaFixture } from '../fixtures/typed'
afterEach(() => {
document.body.innerHTML = ''
@@ -50,6 +50,13 @@ describe('form state helpers', () => {
expect(body).toEqual({ name: 'Blue', tags: [3], active: false })
})
it('never sends widget or partial fields, even with a value under their name', () => {
const fields = [formField('name'), ...extensionFormSchemaFixture.data.fields.filter((field) => field.type === 'widget' || field.type === 'partial')]
expect(fields.map((field) => field.type)).toEqual(['text', 'widget', 'partial'])
expect(editablePayload(fields, { name: 'Blue', lookup: 'x', summary: { nodes: [] } })).toEqual({ name: 'Blue' })
expect(initialValues(fields.slice(1))).toEqual({})
})
it('starts a new record from defaults, toggles off and empty multiple relations', () => {
const fields = [
{ ...formField('name'), default: 'Unnamed' },

View File

@@ -6,11 +6,14 @@ import RelationField from '../../src/components/form/fields/RelationField.vue'
import SwitchField from '../../src/components/form/fields/SwitchField.vue'
import TextField from '../../src/components/form/fields/TextField.vue'
import TextareaField from '../../src/components/form/fields/TextareaField.vue'
import PartialField from '../../src/components/form/fields/PartialField.vue'
import UnsupportedField from '../../src/components/form/fields/UnsupportedField.vue'
import WidgetField from '../../src/components/form/fields/WidgetField.vue'
import RelationManager from '../../src/components/relation/RelationManager.vue'
import {
controlAttributes,
controlClass,
groupLabelled,
isRegistered,
needsRecord,
ownsLabel,
@@ -60,6 +63,26 @@ describe('field renderer registry (D-05)', () => {
})
})
describe('plugin extension types (Phase 10.1, D-09)', () => {
it('registers widget and partial with their controls', () => {
expect(rendererFor('widget')).toBe(WidgetField)
expect(rendererFor('partial')).toBe(PartialField)
})
it.each(['widget', 'partial'])('treats %s as valueless, rendered on create and group-labelled', (type) => {
expect(isRegistered(type)).toBe(false)
expect(needsRecord(type)).toBe(false)
expect(groupLabelled(type)).toBe(true)
expect(ownsLabel(type)).toBe(false)
})
it('labels every other type as a control, not a group', () => {
for (const type of ['text', 'textarea', 'number', 'dropdown', 'switch', 'checkbox', 'relation', RELATION_MANAGER, 'colorpicker']) {
expect(groupLabelled(type), type).toBe(false)
}
})
})
describe('control attributes', () => {
it('passes only allow-listed Winter attributes, lower-cased, and drops null and false', () => {
const field = formField('name')

View File

@@ -55,3 +55,56 @@ describe('list toolbar (D-14)', () => {
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
})
})
describe('registered toolbar actions (D-12, UI-SPEC S4)', () => {
const actions = [
{ name: 'archive', label: 'Archive' },
{ name: 'recount', label: 'Recount' },
]
function names(wrapper: ReturnType<typeof mount>): (string | null)[] {
return wrapper.findAll('[data-action]').map((button) => button.attributes('data-action') ?? null)
}
it('renders custom names after delete in declared order as outline buttons', () => {
const wrapper = mount(ListToolbar, { props: { ...base, buttons: ['delete', 'recount', 'archive'], actions } })
expect(names(wrapper)).toEqual(['delete', 'recount', 'archive'])
const recount = wrapper.find('[data-action="recount"]')
expect(recount.text()).toBe('Recount')
expect(recount.classes()).toContain('border-border-strong')
expect(recount.find('svg').exists()).toBe(false)
})
it('keeps the actions enabled without a selection and while one exists', async () => {
const wrapper = mount(ListToolbar, { props: { ...base, buttons: ['delete', 'recount'], actions } })
const recount = () => wrapper.find('[data-action="recount"]')
expect(recount().attributes('disabled')).toBeUndefined()
expect(recount().attributes('aria-busy')).toBeUndefined()
await wrapper.setProps({ selectedCount: 3, selectedLabel: 'Selected 3' })
expect(recount().attributes('disabled')).toBeUndefined()
await recount().trigger('click')
expect(wrapper.emitted('action')).toEqual([['recount']])
expect(wrapper.emitted('delete')).toBeUndefined()
})
it('disables only the busy action and marks it aria-busy', async () => {
const wrapper = mount(ListToolbar, { props: { ...base, buttons: ['recount', 'archive'], actions, busyAction: 'recount' } })
const recount = wrapper.find('[data-action="recount"]')
expect(recount.attributes('disabled')).toBeDefined()
expect(recount.attributes('aria-busy')).toBe('true')
await recount.trigger('click')
expect(wrapper.emitted('action')).toBeUndefined()
const archive = wrapper.find('[data-action="archive"]')
expect(archive.attributes('disabled')).toBeUndefined()
expect(archive.attributes('aria-busy')).toBeUndefined()
await archive.trigger('click')
expect(wrapper.emitted('action')).toEqual([['archive']])
})
it('does not render names missing from actions or the create button', () => {
const wrapper = mount(ListToolbar, { props: { ...base, buttons: ['create', 'delete', 'launch', 'recount'], actions: [actions[1]!] } })
expect(names(wrapper)).toEqual(['delete', 'recount'])
const bare = mount(ListToolbar, { props: { ...base, buttons: ['recount'] } })
expect(bare.find('[data-action]').exists()).toBe(false)
})
})

View File

@@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import ListView from '../../src/views/ListView.vue'
import { toasts } from '../../src/state/useToasts'
import { clone, listFixture, listSchemaFixture } from '../fixtures/typed'
import { API, mountApp, queryOf, requestsTo, resetState, type Route } from '../helpers'
import { OWNER_ATTRIBUTE } from '../../src/app/pluginAssets'
import { clone, extensionListSchemaFixture, extensionPartialFixture, listFixture, listSchemaFixture } from '../fixtures/typed'
import { API, mountApp, queryOf, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
const serverError = { status: 500, body: { error: { code: 'server_error', message: 'Database down', details: {} } } }
@@ -130,3 +131,170 @@ describe('list view', () => {
expect(wrapper.find('[data-action="delete"]').attributes('disabled')).toBeUndefined()
})
})
describe('list view extension points (Phase 10.1, D-03, D-11, D-12, D-14)', () => {
const STATS = `${BASE}/partials/stats`
const RECOUNT = `${BASE}/toolbar/recount`
/** Plugin files never load in happy-dom: appended scripts and links are recorded instead. */
function stubHead(): { scripts: string[]; links: HTMLLinkElement[] } {
const seen = { scripts: [] as string[], links: [] as HTMLLinkElement[] }
const head = document.head
const append = head.appendChild.bind(head)
vi.spyOn(head, 'appendChild').mockImplementation(<T extends Node>(node: T): T => {
if (node instanceof HTMLScriptElement) {
seen.scripts.push(node.getAttribute('src') ?? '')
queueMicrotask(() => node.dispatchEvent(new Event('load')))
return node
}
if (node instanceof HTMLLinkElement) {
seen.links.push(node)
return node
}
return append(node)
})
return seen
}
function extensionRoutes(overrides: Record<string, Route> = {}): Record<string, Route> {
return routes({
[`GET ${BASE}/schema/list`]: { body: extensionListSchemaFixture },
[`GET ${STATS}`]: { body: extensionPartialFixture },
...overrides,
})
}
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 }
}
it('renders the header partial slot only when the schema names one', async () => {
stubHead()
const plain = await mountApp('/acme/demo/widgets', routes())
expect(plain.wrapper.find('[data-partial]').exists()).toBe(false)
expect(plain.wrapper.find('[data-partial-loading]').exists()).toBe(false)
expect(plain.calls.some((request) => new URL(request.url).pathname.includes('/partials/'))).toBe(false)
plain.wrapper.unmount()
const { wrapper, calls } = await mountApp('/acme/demo/widgets', extensionRoutes())
expect(requestsTo(calls, 'GET', STATS)).toHaveLength(1)
expect(queryOf(requestsTo(calls, 'GET', STATS)[0]!).has('id')).toBe(false)
expect(wrapper.find('[data-partial="header"] dl.summer-stats').exists()).toBe(true)
})
it('refetches the header after a bulk delete and a toolbar action only', async () => {
stubHead()
const { wrapper, calls, router } = await mountApp(
'/acme/demo/widgets',
extensionRoutes({
[`POST ${BASE}/bulk-delete`]: { body: { data: { deleted: 2 }, meta: {} } },
[`POST ${RECOUNT}`]: { body: { data: { message: 'Counts refreshed', fill: {} }, meta: {} } },
}),
{ attach: true },
)
const partials = () => requestsTo(calls, 'GET', STATS).length
expect(partials()).toBe(1)
for (const query of [{ search: 'blue' }, { sort: 'name', dir: 'desc' }, { 'filter[maker]': '1' }, { page: '2' }, { per_page: '50' }]) {
await router.replace({ path: '/acme/demo/widgets', query })
await flushPromises()
expect(partials(), JSON.stringify(query)).toBe(1)
}
await wrapper.find('[data-list-search]').setValue('red')
await wait(350)
await flushPromises()
expect(partials()).toBe(1)
await selectAllAndDelete(wrapper)
expect(partials()).toBe(2)
await wrapper.find('[data-action="recount"]').trigger('click')
await flushPromises()
expect(partials()).toBe(3)
})
it('runs a toolbar action: POSTs {}, toasts and reloads the list', async () => {
stubHead()
const action = deferred()
const { wrapper, calls } = await mountApp('/acme/demo/widgets', extensionRoutes({ [`POST ${RECOUNT}`]: action.route }))
const lists = requestsTo(calls, 'GET', BASE).length
await wrapper.find('[data-action="recount"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-action="recount"]').attributes('aria-busy')).toBe('true')
await wrapper.find('[data-action="recount"]').trigger('click')
const posts = requestsTo(calls, 'POST', RECOUNT)
expect(posts).toHaveLength(1)
expect(await posts[0]!.json()).toEqual({})
expect(posts[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
action.release({ body: { data: { message: 'Counts refreshed', fill: {} }, meta: {} } })
await flushPromises()
expect(toasts.value[0]).toMatchObject({ text: 'Counts refreshed', tone: 'success' })
expect(requestsTo(calls, 'GET', BASE).length).toBe(lists + 1)
expect(wrapper.find('[data-action="recount"]').attributes('aria-busy')).toBeUndefined()
})
it('toasts nothing for an empty message but still reloads', async () => {
stubHead()
const { wrapper, calls } = await mountApp('/acme/demo/widgets', extensionRoutes({ [`POST ${RECOUNT}`]: { body: { data: { message: '', fill: {} }, meta: {} } } }))
const lists = requestsTo(calls, 'GET', BASE).length
await wrapper.find('[data-action="recount"]').trigger('click')
await flushPromises()
expect(toasts.value).toHaveLength(0)
expect(requestsTo(calls, 'GET', BASE).length).toBe(lists + 1)
})
it.each([
['the server message', { status: 422, body: { error: { code: 'validation_failed', message: 'Recount failed.', details: {} } } }, 'Recount failed.'],
['the framework text without one', { status: 500, body: {} }, 'Nie udało się wykonać akcji. Spróbuj ponownie.'],
])('toasts danger with %s and does not reload', async (_, reply, text) => {
stubHead()
const { wrapper, calls } = await mountApp('/acme/demo/widgets', extensionRoutes({ [`POST ${RECOUNT}`]: reply }))
const lists = requestsTo(calls, 'GET', BASE).length
const partials = requestsTo(calls, 'GET', STATS).length
await wrapper.find('[data-action="recount"]').trigger('click')
await flushPromises()
expect(toasts.value[0]).toMatchObject({ text, tone: 'danger' })
expect(requestsTo(calls, 'GET', BASE).length).toBe(lists)
expect(requestsTo(calls, 'GET', STATS).length).toBe(partials)
expect(wrapper.find('[data-action="recount"]').attributes('disabled')).toBeUndefined()
})
it('toasts the framework text when the action cannot reach the server', async () => {
stubHead()
const { wrapper } = await mountApp(
'/acme/demo/widgets',
extensionRoutes({
[`POST ${RECOUNT}`]: () => {
throw new TypeError('offline')
},
}),
)
await wrapper.find('[data-action="recount"]').trigger('click')
await flushPromises()
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się wykonać akcji. Spróbuj ponownie.', tone: 'danger' })
})
it('loads the controller assets when the schema arrives, not before', async () => {
const seen = stubHead()
const schema = deferred()
const envelope = clone(extensionListSchemaFixture)
// Fresh URLs: the loader remembers files across the tests of this file.
envelope.data.assets.scripts = ['/admin-test/assets/acme/demo/js/list.js?v=1']
envelope.data.assets.styles = ['/admin-test/assets/acme/demo/css/list.css?v=1']
await mountApp('/acme/demo/widgets', extensionRoutes({ [`GET ${BASE}/schema/list`]: schema.route }))
expect(seen.links).toHaveLength(0)
expect(seen.scripts).toHaveLength(0)
schema.release({ body: envelope })
await flushPromises()
expect(seen.scripts).toEqual(['/admin-test/assets/acme/demo/js/list.js?v=1'])
expect(seen.links.map((link) => link.getAttribute('href'))).toEqual(['/admin-test/assets/acme/demo/css/list.css?v=1'])
expect(seen.links[0]!.getAttribute(OWNER_ATTRIBUTE)).toBe('acme.demo.widgets')
expect(seen.links[0]!.disabled).toBe(false)
})
})

View File

@@ -0,0 +1,302 @@
// PartialHost and the client node allowlist (D-17; UI-SPEC S1, S2, S5;
// T-10.1-14): the server node tree is rebuilt with h() under the same lists
// the server uses; text is always text. The host fetches through the typed
// client (fetch mock), shows its skeleton, empty, failure and busy-refetch
// states, and sends ?id= only with a record.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import PartialHost from '../../src/components/partial/PartialHost.vue'
import ExtensionFailure from '../../src/components/ui/ExtensionFailure.vue'
import {
PARTIAL_DROPPED_TAGS,
PARTIAL_MAX_DEPTH,
PARTIAL_MAX_NODES,
PARTIAL_TAGS,
partialAttrAllowed,
renderPartialNodes,
} from '../../src/components/partial/partialNodes'
import type { PartialNode } from '../../src/api/types'
import { extensionPartialFixture } from '../fixtures/typed'
import { API, mockApi, queryOf, requestsTo, resetState, type Reply } from '../helpers'
const SOURCE = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
const STATS = `${API}/acme/demo/widgets/partials/stats`
const PARTIAL_FAILED = 'Nie udało się wczytać tej sekcji. Odśwież stronę, aby spróbować ponownie.'
/** Renders nodes through renderPartialNodes into a real element. */
function renderNodes(nodes: PartialNode[]): HTMLElement {
const Probe = defineComponent({ render: () => h('div', { 'data-probe': '' }, renderPartialNodes(nodes)) })
return mount(Probe).element as HTMLElement
}
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 }
}
const nodesReply = (nodes: PartialNode[]): Reply => ({ body: { data: { nodes }, meta: {} } })
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('client allowlist (partialNodes)', () => {
it('mirrors the server tag lists exactly', () => {
expect([...PARTIAL_TAGS].sort()).toEqual(
[
'a', 'abbr', 'b', 'blockquote', 'br', 'caption', 'code', 'data', 'dd', 'div', 'dl', 'dt', 'em', 'figcaption',
'figure', 'footer', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'i', 'img', 'li', 'mark', 'meter', 'ol', 'p',
'pre', 'progress', 'q', 's', 'section', 'small', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'tfoot',
'th', 'thead', 'time', 'tr', 'u', 'ul',
].sort(),
)
expect([...PARTIAL_DROPPED_TAGS].sort()).toEqual(
[
'base', 'button', 'embed', 'form', 'iframe', 'input', 'link', 'math', 'meta', 'noscript', 'object', 'script',
'select', 'style', 'svg', 'template', 'textarea', 'title', 'xmp',
].sort(),
)
expect(PARTIAL_MAX_DEPTH).toBe(32)
expect(PARTIAL_MAX_NODES).toBe(2000)
})
it.each([...PARTIAL_TAGS])('renders an allowed <%s>', (tag) => {
const root = renderNodes([{ tag, children: [{ text: 'x' }] }])
expect(root.firstElementChild?.tagName.toLowerCase()).toBe(tag)
})
it.each([...PARTIAL_DROPPED_TAGS])('drops <%s> with its subtree', (tag) => {
const root = renderNodes([{ text: 'before' }, { tag, children: [{ tag: 'b', children: [{ text: 'inside' }] }] }, { text: 'after' }])
expect(root.textContent).toBe('beforeafter')
expect(root.querySelector(tag)).toBeNull()
expect(root.querySelector('b')).toBeNull()
})
it('unwraps unknown elements and keeps their children, case-insensitively', () => {
const root = renderNodes([
{ tag: 'custom-box', children: [{ tag: 'span', attrs: { class: 'kept' }, children: [{ text: 'child' }] }] },
{ tag: 'ARTICLE', children: [{ text: ' tail' }] },
{ tag: 'SCRIPT', children: [{ text: 'alert(1)' }] },
{ tag: 'P', children: [{ text: 'upper' }] },
])
expect(root.querySelector('custom-box')).toBeNull()
expect(root.querySelector('article')).toBeNull()
expect(root.querySelector('span.kept')?.textContent).toBe('child')
expect(root.querySelector('p')?.textContent).toBe('upper')
expect(root.textContent).toBe('child tailupper')
})
it('keeps text as text, never markup', () => {
const hostile = '<img src=x onerror="alert(1)"><script>alert(2)</script>'
const root = renderNodes([{ tag: 'p', children: [{ text: hostile }] }, { text: '' }, { tag: '', text: '<b>bare</b>' }])
expect(root.querySelector('img')).toBeNull()
expect(root.querySelector('script')).toBeNull()
expect(root.querySelector('p')?.textContent).toBe(hostile)
expect(root.textContent).toBe(`${hostile}<b>bare</b>`)
})
it.each([
['div', 'class', 'x', true],
['div', 'title', 'x', true],
['div', 'lang', 'pl', true],
['div', 'dir', 'rtl', true],
['div', 'role', 'note', true],
['div', 'aria-label', 'x', true],
['div', 'aria-describedby', 'x', true],
['div', 'data-count', '3', true],
['div', 'data-', 'x', false],
['div', 'aria-', 'x', false],
['div', 'id', 'x', false],
['div', 'style', 'color:red', false],
['div', 'onclick', 'x()', false],
['img', 'onerror', 'x()', false],
['div', 'tabindex', '0', false],
['div', 'colspan', '2', false],
['td', 'colspan', '2', true],
['th', 'rowspan', '2', true],
['th', 'scope', 'row', true],
['time', 'datetime', '2026-09-29', true],
['data', 'value', '7', true],
['meter', 'optimum', '1', true],
['progress', 'max', '3', true],
['img', 'alt', 'a', true],
['img', 'width', '10', true],
['a', 'target', '_blank', false],
['a', 'href', '/admin-test/x', true],
['a', 'href', '/', true],
['a', 'href', '#top', true],
['a', 'href', 'javascript:alert(1)', false],
['a', 'href', 'JAVASCRIPT:alert(1)', false],
['a', 'href', 'https://evil.example.test', false],
['a', 'href', '//evil.example.test', false],
['a', 'href', '/\\evil.example.test', false],
['a', 'href', '/\t/evil.example.test', false],
['a', 'href', ' /x', false],
['a', 'href', 'x', false],
['a', 'href', '', false],
['img', 'src', '/a.png', true],
['img', 'src', '#frag', false],
['img', 'src', 'data:image/png;base64,AAAA', false],
['img', 'src', '//evil.example.test/a.png', false],
])('<%s %s=%j> allowed: %s', (tag, name, value, allowed) => {
expect(partialAttrAllowed(tag, name, value)).toBe(allowed)
})
it('applies the attribute rules to rendered nodes and lower-cases names', () => {
const root = renderNodes([
{ tag: 'p', attrs: { CLASS: 'lead', ID: 'hijack', OnClick: 'x()', 'Data-Note': 'kept', style: 'color:red' }, children: [{ text: 'p' }] },
{ tag: 'a', attrs: { href: 'javascript:alert(1)', title: 'bad' }, children: [{ text: 'bad' }] },
{ tag: 'a', attrs: { href: '/admin-test/acme/demo/widgets' }, children: [{ text: 'ok' }] },
])
const paragraph = root.querySelector('p')!
expect(paragraph.getAttributeNames().sort()).toEqual(['class', 'data-note'])
const [bad, ok] = Array.from(root.querySelectorAll('a'))
expect(bad!.hasAttribute('href')).toBe(false)
expect(bad!.getAttribute('title')).toBe('bad')
expect(ok!.getAttribute('href')).toBe('/admin-test/acme/demo/widgets')
})
it('stops at depth 32', () => {
let node: PartialNode = { text: 'deep' }
for (let i = 0; i < PARTIAL_MAX_DEPTH + 1; i++) {
node = { tag: 'div', children: [node] }
}
const root = renderNodes([node])
expect(root.querySelectorAll('div').length).toBe(PARTIAL_MAX_DEPTH)
expect(root.textContent).toBe('')
let fits: PartialNode = { text: 'deep' }
for (let i = 0; i < PARTIAL_MAX_DEPTH; i++) {
fits = { tag: 'div', children: [fits] }
}
expect(renderNodes([fits]).textContent).toBe('deep')
})
it('renders at most 2000 nodes', () => {
const many: PartialNode[] = Array.from({ length: PARTIAL_MAX_NODES + 5 }, (_, i) => ({ tag: 'span', children: [{ text: String(i) }] }))
const root = renderNodes(many)
// Each span costs two nodes: itself and its text.
expect(root.querySelectorAll('span').length).toBe(PARTIAL_MAX_NODES / 2)
expect(renderNodes([])).toBeInstanceOf(HTMLElement)
expect(renderPartialNodes(undefined)).toEqual([])
})
})
describe('PartialHost', () => {
function mountHost(props: { variant?: 'header' | 'field'; recordId?: number | null; reloadKey?: number } = {}) {
return mount(PartialHost, {
props: { source: SOURCE, name: 'stats', variant: props.variant ?? 'header', recordId: props.recordId, reloadKey: props.reloadKey },
})
}
it.each([
['header', ['h-[80px]', 'w-full', 'rounded-card', 'bg-skel']],
['field', ['h-[44px]', 'w-full', 'rounded-control', 'bg-skel']],
] as const)('shows the %s skeleton on the first load', async (variant, classes) => {
const pending = deferred()
mockApi({ [`GET ${STATS}`]: pending.route })
const wrapper = mountHost({ variant })
await flushPromises()
const loading = wrapper.find('[data-partial-loading]')
expect(loading.attributes('aria-busy')).toBe('true')
const skeleton = loading.find('[data-partial-skeleton]')
expect(skeleton.attributes('aria-hidden')).toBe('true')
expect(skeleton.classes()).toEqual(expect.arrayContaining([...classes]))
pending.release({ body: extensionPartialFixture })
await flushPromises()
expect(wrapper.find('[data-partial-loading]').exists()).toBe(false)
const host = wrapper.find(`[data-partial="${variant}"]`)
expect(host.classes()).toContain('summer-partial')
expect(host.attributes('aria-busy')).toBeUndefined()
expect(host.findAll('.summer-stat__label').map((item) => item.text())).toEqual(['All widgets', 'Small'])
expect(host.find('script').exists()).toBe(false)
expect(host.html()).not.toContain('javascript:')
})
it('renders nothing for zero nodes', async () => {
mockApi({ [`GET ${STATS}`]: nodesReply([]) })
const wrapper = mountHost()
await flushPromises()
expect(wrapper.find('[data-partial]').exists()).toBe(false)
expect(wrapper.find('[data-partial-loading]').exists()).toBe(false)
expect(wrapper.find('[data-partial-failed]').exists()).toBe(false)
expect(wrapper.element.nodeType).toBe(Node.COMMENT_NODE)
})
it.each([
['a 500', { status: 500, body: { error: { code: 'server_error', message: 'Server error', details: {} } } }],
['a 404', { status: 404, body: { error: { code: 'not_found', message: 'Not found', details: {} } } }],
['a 403', { status: 403, body: {} }],
])('shows the failure box on %s', async (_, reply) => {
mockApi({ [`GET ${STATS}`]: reply })
const wrapper = mountHost()
await flushPromises()
const box = wrapper.find('[data-partial-failed]')
expect(box.attributes('role')).toBe('alert')
expect(box.text()).toBe(PARTIAL_FAILED)
expect(wrapper.findComponent(ExtensionFailure).exists()).toBe(true)
})
it('shows the failure box when the request cannot be sent', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network down'))
const wrapper = mountHost()
await flushPromises()
expect(wrapper.find('[data-partial-failed]').text()).toBe(PARTIAL_FAILED)
})
it('keeps the previous nodes with aria-busy while a reload runs', async () => {
const later = deferred()
let gets = 0
const calls = mockApi({ [`GET ${STATS}`]: () => (++gets === 1 ? nodesReply([{ tag: 'p', children: [{ text: 'First' }] }]) : later.route()) })
const wrapper = mountHost()
await flushPromises()
expect(wrapper.find('[data-partial="header"]').text()).toBe('First')
await wrapper.setProps({ reloadKey: 1 })
await flushPromises()
expect(requestsTo(calls, 'GET', STATS)).toHaveLength(2)
const host = wrapper.find('[data-partial="header"]')
expect(host.attributes('aria-busy')).toBe('true')
expect(host.text()).toBe('First')
expect(wrapper.find('[data-partial-skeleton]').exists()).toBe(false)
later.release(nodesReply([{ tag: 'p', children: [{ text: 'Second' }] }]))
await flushPromises()
expect(wrapper.find('[data-partial="header"]').attributes('aria-busy')).toBeUndefined()
expect(wrapper.find('[data-partial="header"]').text()).toBe('Second')
})
it('keeps only the latest answer when reloads overlap', async () => {
const first = deferred()
const second = deferred()
let gets = 0
mockApi({ [`GET ${STATS}`]: () => (++gets === 1 ? first.route() : second.route()) })
const wrapper = mountHost()
await wrapper.setProps({ reloadKey: 1 })
second.release(nodesReply([{ text: 'newest' }]))
await flushPromises()
first.release(nodesReply([{ text: 'stale' }]))
await flushPromises()
expect(wrapper.text()).toBe('newest')
})
it('sends ?id= only with a record', async () => {
const calls = mockApi({ [`GET ${STATS}`]: nodesReply([{ text: 'x' }]) })
mountHost({ variant: 'field' })
mountHost({ variant: 'field', recordId: null })
mountHost({ variant: 'field', recordId: 42 })
await flushPromises()
const queries = requestsTo(calls, 'GET', STATS).map((request) => queryOf(request))
expect(queries.map((query) => query.get('id'))).toEqual([null, null, '42'])
expect(queries.map((query) => [...query.keys()].length)).toEqual([0, 0, 1])
})
})