Files
summercms/admin/tests/smoke/extension.smoke.test.ts
Jakub Zych 5bbb0ada05 fix(10.1): WR-01 keep a late schema response from restyling the current view
loadControllerAssets no longer activates stylesheets. activateStyles,
called only by the router, records the controller on screen, and new
links are created disabled unless they belong to it, so a list or form
schema that resolves after its view was left adds its links disabled
instead of switching plugin CSS to the wrong controller. Rebuilt the
embedded admin dist.
2026-09-29 09:50:05 +02:00

673 lines
29 KiB
TypeScript

// Phase 10.1 extension point, SPA half (ADMIN-07): plugin assets, a widget
// custom element bridged to its action, header and form partials rendered
// through the client allowlist, registered toolbar actions and stylesheets
// scoped to their controller. Fixtures are neutral acme.demo.* data; no
// application names appear in framework tests.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, type VueWrapper } from '@vue/test-utils'
import {
activateStyles,
assetAllowed,
loadControllerAssets,
loadScript,
loadStyles,
OWNER_ATTRIBUTE,
} from '../../src/app/pluginAssets'
import {
FORM_ASSETS,
FORM_LOCALE,
FORM_PATCH,
FORM_VALUES,
WIDGET_EVENT,
WIDGET_TIMEOUT,
} from '../../src/components/form/formContext'
import WidgetField from '../../src/components/form/fields/WidgetField.vue'
import PartialField from '../../src/components/form/fields/PartialField.vue'
import ExtensionFailure from '../../src/components/ui/ExtensionFailure.vue'
import PartialHost from '../../src/components/partial/PartialHost.vue'
import {
PARTIAL_DROPPED_TAGS,
PARTIAL_TAGS,
partialAttrAllowed,
renderPartialNodes,
} from '../../src/components/partial/partialNodes'
import { groupLabelled, isRegistered, needsRecord, rendererFor } from '../../src/components/form/registry'
import listRows from '../fixtures/widgets.list.json'
import {
clone,
extensionFormSchemaFixture,
extensionListSchemaFixture,
extensionPartialFixture,
} from '../fixtures/typed'
import { API, mountApp, queryOf, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
const RECORD = `${BASE}/1`
const WIDGET_ACTION = `${BASE}/widgets/lookup`
const SCRIPT = '/admin-test/assets/acme/demo/js/lookup.js?v=abc'
const STATS = `${BASE}/partials/stats`
const SUMMARY = `${BASE}/partials/summary`
const summary = { data: { nodes: [{ tag: 'p', children: [{ text: 'Summary of the widget' }] }] }, meta: {} }
const record = { data: { id: 1, name: 'Blue widget', color: '#0000ff' }, meta: { labels: {} } }
// The plugin's element: plain JS, no Vue, defined before the form mounts as
// the plugin script would.
class AcmeDemoLookup extends HTMLElement {}
if (!customElements.get('acme-demo-lookup')) {
customElements.define('acme-demo-lookup', AcmeDemoLookup)
}
interface Seen {
scripts: string[]
links: HTMLLinkElement[]
}
/**
* Plugin files never load in happy-dom: a script "loads" on the next
* microtask (or errors when listed in failing) and links are only recorded.
*/
function stubAssets(failing: string[] = []): Seen {
const seen: Seen = { scripts: [], links: [] }
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) {
const src = node.getAttribute('src') ?? ''
seen.scripts.push(src)
queueMicrotask(() => node.dispatchEvent(new Event(failing.includes(src) ? 'error' : 'load')))
return node
}
if (node instanceof HTMLLinkElement) {
seen.links.push(node)
return node
}
return append(node)
})
return seen
}
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/form`]: { body: extensionFormSchemaFixture },
[`GET ${RECORD}`]: { body: record },
[`GET ${SUMMARY}`]: { body: summary },
[`PUT ${RECORD}`]: async (request) => ({
body: { data: { ...record.data, ...((await request.json()) as object) }, meta: record.meta },
}),
...overrides,
}
}
/** A route whose reply the test releases, to observe the in-flight state. */
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 }
}
function widget(wrapper: VueWrapper): HTMLElement {
const element = wrapper.find('acme-demo-lookup').element
if (!(element instanceof HTMLElement)) {
throw new Error('widget element not mounted')
}
return element
}
function act(element: HTMLElement): void {
element.dispatchEvent(new CustomEvent(WIDGET_EVENT, { bubbles: true, composed: true }))
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('plugin assets (D-14, D-16)', () => {
it('accepts only same-origin files under {base}/assets/', () => {
expect(assetAllowed(SCRIPT)).toBe(true)
for (const url of [
'https://cdn.example.test/admin-test/assets/x.js',
'//cdn.example.test/admin-test/assets/x.js',
'/admin-test/api/v1/x.js',
'/admin-test/assets/../api/v1/x.js',
'/admin-test/assets/a\\b.js',
'/admin-test/assets/a b.js',
'/other/assets/x.js',
]) {
expect(assetAllowed(url), url).toBe(false)
}
})
it('loads a script once per URL, refuses foreign URLs and retries after a failure', async () => {
const failing = '/admin-test/assets/acme/demo/js/broken.js?v=1'
const seen = stubAssets([failing])
await expect(loadScript('https://cdn.example.test/x.js')).rejects.toThrow()
const url = '/admin-test/assets/acme/demo/js/once.js?v=1'
const first = loadScript(url)
expect(loadScript(url)).toBe(first)
await first
await expect(loadScript(failing)).rejects.toThrow()
await expect(loadScript(failing)).rejects.toThrow()
expect(seen.scripts).toEqual([url, failing, failing])
expect(await loadControllerAssets('acme.demo.widgets', { scripts: [url, failing], styles: [] })).toEqual([failing])
})
it('owns stylesheet links per controller and disables the other controllers', () => {
const seen = stubAssets()
const css = '/admin-test/assets/acme/demo/css/demo.css?v=1'
loadStyles('acme.demo.one', [css, 'https://cdn.example.test/x.css'])
loadStyles('acme.demo.one', [css])
loadStyles('acme.demo.two', [css])
expect(seen.links).toHaveLength(2)
const [one, two] = seen.links
expect(one!.getAttribute(OWNER_ATTRIBUTE)).toBe('acme.demo.one')
expect(one!.rel).toBe('stylesheet')
activateStyles('acme.demo.two')
expect(one!.disabled).toBe(true)
expect(two!.disabled).toBe(false)
activateStyles('acme.demo.one')
expect(one!.disabled).toBe(false)
expect(two!.disabled).toBe(true)
})
})
describe('widget field (D-04, D-05, D-07, D-08)', () => {
it('is a registered, valueless control that renders on create', () => {
expect(rendererFor('widget')).toBe(WidgetField)
expect(isRegistered('widget')).toBe(false)
expect(needsRecord('widget')).toBe(false)
expect(FORM_VALUES).not.toBe(FORM_PATCH)
expect(FORM_LOCALE).not.toBe(FORM_ASSETS)
expect(WIDGET_TIMEOUT).toBe(5000)
})
it('mounts the element with attributes only and keeps fill-values current', async () => {
const seen = stubAssets()
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
await flushPromises()
expect(seen.scripts).toContain(SCRIPT)
const host = wrapper.find('[data-widget-host]')
expect(host.attributes('role')).toBe('group')
expect(host.attributes('id')).toBe('field-lookup')
expect(host.attributes('aria-labelledby')).toBe('field-lookup-label')
expect(host.attributes('aria-describedby')).toBe('field-lookup-comment')
expect(host.attributes('aria-busy')).toBeUndefined()
expect(wrapper.find('#field-lookup-label').text()).toBe('Catalogue')
expect(wrapper.find('label[for="field-lookup"]').exists()).toBe(false)
expect(wrapper.find('[data-widget-skeleton]').exists()).toBe(false)
const element = widget(wrapper)
expect(element.getAttribute('record-id')).toBe('1')
expect(element.getAttribute('field-name')).toBe('lookup')
expect(element.getAttribute('locale')).toBe('en')
expect(element.getAttribute('fill-values')).toBe('{"name":"Blue widget"}')
expect(element.getAttribute('label')).toBe('Look up')
expect(element.getAttribute('busy-label')).toBe('Wczytywanie…')
expect(element.getAttributeNames().sort()).toEqual(['busy-label', 'field-name', 'fill-values', 'label', 'locale', 'record-id'])
expect(Object.keys(element)).toEqual([])
await wrapper.find('#field-name').setValue('Red widget')
expect(element.getAttribute('fill-values')).toBe('{"name":"Red widget"}')
})
it('posts the action once while busy and patches only the fill keys the server returned', async () => {
stubAssets()
const action = deferred()
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes({ [`POST ${WIDGET_ACTION}`]: action.route }))
await flushPromises()
const element = widget(wrapper)
act(element)
await flushPromises()
expect(element.hasAttribute('busy')).toBe(true)
act(element)
await flushPromises()
const posts = requestsTo(calls, 'POST', WIDGET_ACTION)
expect(posts).toHaveLength(1)
expect(posts[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(await posts[0]!.json()).toEqual({ record_id: 1, values: { name: 'Blue widget' } })
action.release({ body: { data: { message: 'Name filled in', fill: { name: 'Catalogue widget', color: '#ff0000' } }, meta: {} } })
await flushPromises()
expect(element.hasAttribute('busy')).toBe(false)
expect(element.hasAttribute('state')).toBe(false)
expect((wrapper.find('#field-name').element as HTMLInputElement).value).toBe('Catalogue widget')
expect((wrapper.find('#field-color').element as HTMLInputElement).value).toBe('#0000ff')
expect(wrapper.find('[role="status"]').text()).toContain('Name filled in')
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(0)
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
const [put] = requestsTo(calls, 'PUT', RECORD)
expect(await put!.json()).toEqual({ name: 'Catalogue widget', color: '#0000ff' })
})
it('toasts the server message, marks the element and leaves the values on failure', async () => {
stubAssets()
const { wrapper } = await mountApp(
'/acme/demo/widgets/1',
routes({
[`POST ${WIDGET_ACTION}`]: {
status: 422,
body: { error: { code: 'validation_failed', message: 'Catalogue unreachable.', details: {} } },
},
}),
)
await flushPromises()
const element = widget(wrapper)
act(element)
await flushPromises()
expect(element.getAttribute('state')).toBe('error')
expect(element.hasAttribute('busy')).toBe(false)
expect(wrapper.find('[data-tone="danger"]').text()).toContain('Catalogue unreachable.')
expect((wrapper.find('#field-name').element as HTMLInputElement).value).toBe('Blue widget')
})
it('falls back to the framework message when the error has none', async () => {
stubAssets()
const { wrapper } = await mountApp(
'/acme/demo/widgets/1',
routes({ [`POST ${WIDGET_ACTION}`]: { status: 500, body: {} } }),
)
await flushPromises()
act(widget(wrapper))
await flushPromises()
expect(wrapper.find('[data-tone="danger"]').text()).toContain('Nie udało się wykonać akcji. Spróbuj ponownie.')
})
it('renders on create with an empty record id and empty fill values', async () => {
stubAssets()
const action = deferred()
const { wrapper, calls } = await mountApp('/acme/demo/widgets/create', routes({ [`POST ${WIDGET_ACTION}`]: action.route }))
await flushPromises()
const element = widget(wrapper)
expect(element.getAttribute('record-id')).toBe('')
expect(element.getAttribute('fill-values')).toBe('{}')
act(element)
await flushPromises()
expect(await requestsTo(calls, 'POST', WIDGET_ACTION)[0]!.json()).toEqual({ values: {} })
action.release({ body: { data: { message: 'Done', fill: {} }, meta: {} } })
await flushPromises()
})
it('shows the failure box instead of the element when its script fails', async () => {
const schema = clone(extensionFormSchemaFixture)
const broken = '/admin-test/assets/acme/demo/js/missing.js?v=1'
schema.data.assets.scripts = [broken]
schema.data.fields = schema.data.fields.map((field) =>
field.name === 'lookup' ? { ...field, widget: 'acme-demo-missing' } : field,
)
stubAssets([broken])
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: { body: schema } }))
await flushPromises()
const box = wrapper.find('[data-widget-failed]')
expect(box.attributes('role')).toBe('alert')
expect(box.attributes('id')).toBe('field-lookup')
expect(box.text()).toBe('Nie udało się wczytać tej kontrolki. Odśwież stronę, a jeśli błąd wróci, sprawdź zasoby wtyczki.')
expect(wrapper.find('acme-demo-missing').exists()).toBe(false)
expect(wrapper.findComponent(ExtensionFailure).exists()).toBe(true)
})
})
function listRoutes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/list`]: { body: extensionListSchemaFixture },
[`GET ${BASE}`]: { body: listRows },
[`GET ${STATS}`]: { body: extensionPartialFixture },
...overrides,
}
}
describe('partial node allowlist (D-17)', () => {
it('mirrors the server lists and checks attributes and URLs', () => {
expect(PARTIAL_TAGS.has('dl')).toBe(true)
expect(PARTIAL_TAGS.has('script')).toBe(false)
expect(PARTIAL_DROPPED_TAGS.has('script')).toBe(true)
expect(partialAttrAllowed('a', 'href', '/admin-test/x')).toBe(true)
expect(partialAttrAllowed('a', 'href', '#top')).toBe(true)
expect(partialAttrAllowed('img', 'src', '#top')).toBe(false)
for (const href of ['javascript:alert(1)', '//evil.example.test', '/\\evil', 'https://evil.example.test', '/\tx']) {
expect(partialAttrAllowed('a', 'href', href), href).toBe(false)
}
expect(partialAttrAllowed('div', 'onclick', 'x')).toBe(false)
expect(partialAttrAllowed('div', 'id', 'x')).toBe(false)
expect(partialAttrAllowed('div', 'style', 'x')).toBe(false)
expect(partialAttrAllowed('div', 'aria-label', 'x')).toBe(true)
expect(partialAttrAllowed('td', 'colspan', '2')).toBe(true)
expect(partialAttrAllowed('div', 'colspan', '2')).toBe(false)
expect(renderPartialNodes([{ text: '<b>x</b>' }])).toEqual(['<b>x</b>'])
expect(renderPartialNodes([{ tag: 'script', children: [{ text: 'x' }] }])).toEqual([])
})
})
describe('list header partial (D-03, D-11, UI-SPEC S1)', () => {
it('renders the stats strip between the heading and the list card and drops hostile nodes', async () => {
stubAssets()
const { wrapper } = await mountApp('/acme/demo/widgets', listRoutes())
const host = wrapper.find('[data-partial="header"]')
expect(host.classes()).toContain('summer-partial')
expect(host.attributes('aria-busy')).toBeUndefined()
const section = host.element.parentElement!
const blocks = Array.from(section.children)
expect(blocks.indexOf(host.element)).toBe(1)
expect(blocks[0]!.tagName).toBe('HEADER')
expect(blocks[2]!.querySelector('table')).not.toBeNull()
expect(host.find('dl.summer-stats').exists()).toBe(true)
expect(host.findAll('.summer-stat').length).toBe(2)
expect(host.findAll('.summer-stat__label').map((item) => item.text())).toEqual(['All widgets', 'Small'])
expect(host.findAll('.summer-stat__value').map((item) => item.text())).toEqual(['12', '5'])
expect(host.find('script').exists()).toBe(false)
expect(host.text()).not.toContain('hijacked')
const paragraph = host.find('p[data-note="kept"]')
expect(paragraph.attributes('id')).toBeUndefined()
expect(paragraph.attributes('style')).toBeUndefined()
const unsafe = host.find('a.danger-link')
expect(unsafe.attributes('href')).toBeUndefined()
expect(unsafe.attributes('onclick')).toBeUndefined()
const links = host.findAll('a')
expect(links[1]!.attributes('href')).toBe('/admin-test/acme/demo/widgets')
expect(links[1]!.attributes('title')).toBe('All widgets')
expect(links[2]!.attributes('href')).toBeUndefined()
expect(host.find('custom-box').exists()).toBe(false)
const unwrapped = host.find('p.unwrapped')
expect(unwrapped.text()).toBe('Use <b>bold</b> & <i>italic</i>')
expect(unwrapped.find('b').exists()).toBe(false)
expect(wrapper.findComponent(PartialHost).props('variant')).toBe('header')
})
it('shows one skeleton block on the first load without holding back the table', async () => {
stubAssets()
const stats = deferred()
const { wrapper } = await mountApp('/acme/demo/widgets', listRoutes({ [`GET ${STATS}`]: stats.route }))
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(['h-[80px]', 'w-full', 'rounded-card', 'bg-skel']))
expect(wrapper.findAll('tbody tr').length).toBe(2)
stats.release({ body: extensionPartialFixture })
await flushPromises()
expect(wrapper.find('[data-partial-loading]').exists()).toBe(false)
expect(wrapper.find('dl.summer-stats').exists()).toBe(true)
})
it('renders nothing for zero nodes', async () => {
stubAssets()
const { wrapper } = await mountApp('/acme/demo/widgets', listRoutes({ [`GET ${STATS}`]: { body: { data: { nodes: [] }, meta: {} } } }))
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)
})
it('shows the failure box on a 500 and keeps the list usable', async () => {
stubAssets()
const { wrapper } = await mountApp(
'/acme/demo/widgets',
listRoutes({ [`GET ${STATS}`]: { status: 500, body: { error: { code: 'server_error', message: 'Server error', details: {} } } } }),
)
const box = wrapper.find('[data-partial-failed]')
expect(box.attributes('role')).toBe('alert')
expect(box.text()).toBe('Nie udało się wczytać tej sekcji. Odśwież stronę, aby spróbować ponownie.')
expect(wrapper.find('[data-partial]').exists()).toBe(false)
expect(wrapper.findAll('tbody tr').length).toBe(2)
})
it('refetches after a bulk delete with the previous nodes kept, and not on search', async () => {
stubAssets()
const later = deferred()
let gets = 0
const { wrapper, calls } = await mountApp(
'/acme/demo/widgets',
listRoutes({
[`GET ${STATS}`]: () => (++gets === 1 ? { body: extensionPartialFixture } : later.route()),
[`POST ${BASE}/bulk-delete`]: { body: { data: { deleted: 2 }, meta: {} } },
}),
{ attach: true },
)
expect(requestsTo(calls, 'GET', STATS)).toHaveLength(1)
await wrapper.find('[data-list-search]').setValue('blue')
await wait(350)
await flushPromises()
expect(requestsTo(calls, 'GET', STATS)).toHaveLength(1)
await wrapper.find('th[data-select] [role="checkbox"]').trigger('click')
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLButtonElement).click()
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.find('dl.summer-stats').exists()).toBe(true)
expect(wrapper.find('[data-partial-skeleton]').exists()).toBe(false)
later.release({ body: { data: { nodes: [{ tag: 'p', children: [{ text: 'Nothing left' }] }] }, meta: {} } })
await flushPromises()
expect(wrapper.find('[data-partial="header"]').attributes('aria-busy')).toBeUndefined()
expect(wrapper.find('[data-partial="header"]').text()).toBe('Nothing left')
})
})
describe('form partial (D-09, UI-SPEC S2)', () => {
it('is a registered, valueless, group-labelled type', () => {
expect(rendererFor('partial')).toBe(PartialField)
expect(isRegistered('partial')).toBe(false)
expect(needsRecord('partial')).toBe(false)
expect(groupLabelled('partial')).toBe(true)
expect(groupLabelled('widget')).toBe(true)
expect(groupLabelled('text')).toBe(false)
})
it('renders on update with ?id= inside a labelled group', async () => {
stubAssets()
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes())
await flushPromises()
const [get] = requestsTo(calls, 'GET', SUMMARY)
expect(queryOf(get!).get('id')).toBe('1')
const group = wrapper.find('[data-partial-field]')
expect(group.attributes('role')).toBe('group')
expect(group.attributes('aria-labelledby')).toBe('field-summary-label')
expect(wrapper.find('#field-summary-label').text()).toBe('Summary')
expect(group.find('[data-partial="field"]').text()).toBe('Summary of the widget')
})
it('renders on create without an id, shows its skeleton and no label row without a label', async () => {
stubAssets()
const schema = clone(extensionFormSchemaFixture)
schema.data.fields = schema.data.fields.map((field) => (field.name === 'summary' ? { ...field, label: undefined } : field))
const pending = deferred()
const { wrapper, calls } = await mountApp(
'/acme/demo/widgets/create',
routes({ [`GET ${BASE}/schema/form`]: { body: schema }, [`GET ${SUMMARY}`]: pending.route }),
)
const [get] = requestsTo(calls, 'GET', SUMMARY)
expect(queryOf(get!).has('id')).toBe(false)
const skeleton = wrapper.find('[data-partial-field] [data-partial-skeleton]')
expect(skeleton.classes()).toEqual(expect.arrayContaining(['h-[44px]', 'rounded-control', 'bg-skel']))
expect(wrapper.find('#field-summary-label').exists()).toBe(false)
expect(wrapper.find('[data-field="summary"] label').exists()).toBe(false)
expect(wrapper.find('[data-partial-field]').attributes('aria-labelledby')).toBeUndefined()
pending.release({ body: { data: { nodes: [] }, meta: {} } })
await flushPromises()
expect(wrapper.find('[data-partial-field]').text()).toBe('')
})
it('shows the failure box in its row and keeps the form saveable', async () => {
stubAssets()
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${SUMMARY}`]: { status: 500, body: {} } }))
await flushPromises()
expect(wrapper.find('[data-partial-field] [data-partial-failed]').text()).toBe(
'Nie udało się wczytać tej sekcji. Odśwież stronę, aby spróbować ponownie.',
)
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
const [put] = requestsTo(calls, 'PUT', RECORD)
expect(await put!.json()).toEqual({ name: 'Blue widget', color: '#0000ff' })
})
})
describe('toolbar actions (D-12, UI-SPEC S4)', () => {
const RECOUNT = `${BASE}/toolbar/recount`
it('renders a registered action after delete, enabled without a selection', async () => {
stubAssets()
const { wrapper } = await mountApp('/acme/demo/widgets', listRoutes())
const cluster = wrapper.find('[data-action="delete"]').element.parentElement!
const names = Array.from(cluster.querySelectorAll('[data-action]')).map((item) => item.getAttribute('data-action'))
expect(names).toEqual(['delete', 'recount'])
const recount = wrapper.find('[data-action="recount"]')
expect(recount.text()).toBe('Recount')
expect(recount.attributes('disabled')).toBeUndefined()
expect(recount.attributes('aria-busy')).toBeUndefined()
expect(recount.classes()).toContain('border-border-strong')
expect(recount.find('svg').exists()).toBe(false)
expect(wrapper.find('[data-action="delete"]').attributes('disabled')).toBeDefined()
})
it('posts {} while busy, toasts, reloads the list and refetches the header partial', async () => {
stubAssets()
const action = deferred()
const { wrapper, calls } = await mountApp('/acme/demo/widgets', listRoutes({ [`POST ${RECOUNT}`]: action.route }))
const lists = requestsTo(calls, 'GET', BASE).length
const partials = requestsTo(calls, 'GET', STATS).length
await wrapper.find('[data-action="recount"]').trigger('click')
await flushPromises()
const recount = wrapper.find('[data-action="recount"]')
expect(recount.attributes('disabled')).toBeDefined()
expect(recount.attributes('aria-busy')).toBe('true')
await recount.trigger('click')
const [post, ...more] = requestsTo(calls, 'POST', RECOUNT)
expect(more).toHaveLength(0)
expect(post!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(await post!.json()).toEqual({})
action.release({ body: { data: { message: 'Counts refreshed', fill: {} }, meta: {} } })
await flushPromises()
expect(wrapper.find('[role="status"]').text()).toContain('Counts refreshed')
expect(requestsTo(calls, 'GET', BASE).length).toBe(lists + 1)
expect(requestsTo(calls, 'GET', STATS).length).toBe(partials + 1)
expect(wrapper.find('[data-action="recount"]').attributes('disabled')).toBeUndefined()
expect(wrapper.find('[data-action="recount"]').attributes('aria-busy')).toBeUndefined()
})
it('never renders an action the admin may not run', async () => {
stubAssets()
const schema = clone(extensionListSchemaFixture)
schema.data.toolbarActions = []
const { wrapper } = await mountApp('/acme/demo/widgets', listRoutes({ [`GET ${BASE}/schema/list`]: { body: schema } }))
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true)
expect(wrapper.find('[data-action="recount"]').exists()).toBe(false)
expect(wrapper.find('[data-action="create"]').exists()).toBe(true)
})
it('shows a danger toast with the server message or the framework text', async () => {
stubAssets()
let reply: Reply = { status: 500, body: { error: { code: 'server_error', message: 'Recount failed.', details: {} } } }
const { wrapper, calls } = await mountApp('/acme/demo/widgets', listRoutes({ [`POST ${RECOUNT}`]: () => reply }))
const lists = requestsTo(calls, 'GET', BASE).length
await wrapper.find('[data-action="recount"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-tone="danger"]').text()).toContain('Recount failed.')
expect(requestsTo(calls, 'GET', BASE).length).toBe(lists)
reply = { status: 403, body: {} }
await wrapper.find('[data-action="recount"]').trigger('click')
await flushPromises()
expect(wrapper.findAll('[data-tone="danger"]')[0]!.text()).toContain('Nie udało się wykonać akcji. Spróbuj ponownie.')
})
})
describe('controller stylesheets (D-14, UI-SPEC S6)', () => {
it('disables the first controller stylesheet when another controller opens', async () => {
const seen = stubAssets()
const gadgets = `${API}/acme/demo/gadgets`
const widgetsCss = '/admin-test/assets/acme/demo/css/scoped-widgets.css?v=1'
const gadgetsCss = '/admin-test/assets/acme/demo/css/scoped-gadgets.css?v=1'
const widgetsSchema = clone(extensionListSchemaFixture)
widgetsSchema.data.assets.styles = [widgetsCss]
const gadgetsSchema = clone(extensionListSchemaFixture)
gadgetsSchema.data.assets.styles = [gadgetsCss]
delete gadgetsSchema.data.headerPartial
const { router } = await mountApp(
'/acme/demo/widgets',
listRoutes({
[`GET ${BASE}/schema/list`]: { body: widgetsSchema },
[`GET ${gadgets}/schema/list`]: { body: gadgetsSchema },
[`GET ${gadgets}`]: { body: listRows },
}),
)
const link = (url: string) => seen.links.find((item) => item.getAttribute('href') === url)
expect(link(widgetsCss)!.getAttribute(OWNER_ATTRIBUTE)).toBe('acme.demo.widgets')
expect(link(widgetsCss)!.disabled).toBe(false)
await router.push('/acme/demo/gadgets')
await flushPromises()
expect(link(gadgetsCss)!.getAttribute(OWNER_ATTRIBUTE)).toBe('acme.demo.gadgets')
expect(link(gadgetsCss)!.disabled).toBe(false)
expect(link(widgetsCss)!.disabled).toBe(true)
await router.push('/acme/demo/widgets')
await flushPromises()
expect(link(widgetsCss)!.disabled).toBe(false)
expect(link(gadgetsCss)!.disabled).toBe(true)
expect(seen.links.filter((item) => item.getAttribute('href') === widgetsCss)).toHaveLength(1)
})
it('keeps a late schema response of a view already left from restyling the current one (WR-01)', async () => {
const seen = stubAssets()
const gadgets = `${API}/acme/demo/gadgets`
const lateCss = '/admin-test/assets/acme/demo/css/late-widgets.css?v=1'
const currentCss = '/admin-test/assets/acme/demo/css/current-gadgets.css?v=1'
const widgetsSchema = clone(extensionListSchemaFixture)
widgetsSchema.data.assets.styles = [lateCss]
const gadgetsSchema = clone(extensionListSchemaFixture)
gadgetsSchema.data.assets.styles = [currentCss]
delete gadgetsSchema.data.headerPartial
let release: (reply: Reply) => void = () => undefined
const pending = new Promise<Reply>((resolve) => {
release = resolve
})
const { router } = await mountApp(
'/acme/demo/widgets',
listRoutes({
[`GET ${BASE}/schema/list`]: () => pending,
[`GET ${gadgets}/schema/list`]: { body: gadgetsSchema },
[`GET ${gadgets}`]: { body: listRows },
}),
)
await router.push('/acme/demo/gadgets')
await flushPromises()
const link = (url: string) => seen.links.find((item) => item.getAttribute('href') === url)
expect(link(currentCss)!.disabled).toBe(false)
release({ body: widgetsSchema })
await flushPromises()
expect(link(currentCss)!.disabled).toBe(false)
expect(link(lateCss)!.disabled).toBe(true)
})
})