// 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((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 = {}): Record { 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; release: (reply: Reply) => void } { let release: (reply: Reply) => void = () => undefined const pending = new Promise((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 = {}): Record { 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: 'x' }])).toEqual(['x']) 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 bold & italic') 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((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) }) })