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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
302
admin/tests/list/PartialHost.test.ts
Normal file
302
admin/tests/list/PartialHost.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user