// Phase 10.1 extension point, SPA half (ADMIN-07): plugin assets, a widget // custom element bridged to its action. 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 ExtensionFailure from '../../src/components/ui/ExtensionFailure.vue' import { isRegistered, needsRecord, rendererFor } from '../../src/components/form/registry' import { clone, extensionFormSchemaFixture } from '../fixtures/typed' import { API, mountApp, requestsTo, resetState, 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 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 }, [`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: Route; 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) }) })