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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user