From 9f5e807af69ec06c69f9a82e0729e0935ffbb5ae Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 10 Oct 2026 16:03:07 +0200 Subject: [PATCH] test(16.2-06): admin SPA coverage of plugin field types and View site - fieldTypes: one load per script URL, failed and refused scripts reported, global styles enabled on every screen, empty list on failure - registry, formState, FormField and ListView: plugin renderers, locale maps, notices as literal text, unknown column types as text - AppShell: the View site link's href, target, rel and en/pl label --- admin/tests/app/fieldTypes.test.ts | 155 +++++++++++++++++++++++++++++ admin/tests/form/FormField.test.ts | 35 +++++++ admin/tests/form/formState.test.ts | 43 ++++++++ admin/tests/form/registry.test.ts | 34 +++++++ admin/tests/list/ListView.test.ts | 15 +++ admin/tests/shell/AppShell.test.ts | 40 ++++++++ 6 files changed, 322 insertions(+) create mode 100644 admin/tests/app/fieldTypes.test.ts diff --git a/admin/tests/app/fieldTypes.test.ts b/admin/tests/app/fieldTypes.test.ts new file mode 100644 index 0000000..fe30947 --- /dev/null +++ b/admin/tests/app/fieldTypes.test.ts @@ -0,0 +1,155 @@ +// Plugin field type loader (Phase 16.2 D-13): GET {api}/field-types once, +// every script URL loaded once as a module script, styles enabled globally +// on every screen, and nothing ever rejects. Files never load in happy-dom: +// appended scripts and links go into a detached sandbox the test inspects, +// and the test fires each script's load or error event itself. The asset +// loader's state lives for the whole file, so every test uses its own URLs. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fieldTypeScripts, loadFieldTypes, resetFieldTypes } from '../../src/app/fieldTypes' +import { activateStyles, GLOBAL_STYLE_OWNER, loadStyles, OWNER_ATTRIBUTE } from '../../src/app/pluginAssets' +import { API, mockApi, type Route } from '../helpers' + +let sandbox: HTMLDivElement + +function captureHead(): void { + sandbox = document.createElement('div') + const head = document.head + const append = head.appendChild.bind(head) + vi.spyOn(head, 'appendChild').mockImplementation((node: T): T => { + if (node instanceof HTMLScriptElement || node instanceof HTMLLinkElement) { + return sandbox.appendChild(node) + } + return append(node) + }) +} + +function scripts(src: string): HTMLScriptElement[] { + return Array.from(sandbox.querySelectorAll('script')).filter((script) => script.getAttribute('src') === src) +} + +function links(href: string): HTMLLinkElement[] { + return Array.from(sandbox.querySelectorAll('link')).filter((link) => link.getAttribute('href') === href) +} + +const asset = (file: string) => `/admin-test/assets/acme/demo/${file}` + +function types(body: unknown): Record { + return { [`GET ${API}/field-types`]: { body } } +} + +function entry(name: string, scriptUrls: string[], styleUrls: string[] = []) { + return { name, element: `acme-demo-${name}`, translatable: false, scripts: scriptUrls, styles: styleUrls } +} + +beforeEach(() => { + resetFieldTypes() + captureHead() +}) + +afterEach(() => { + vi.restoreAllMocks() + sandbox.remove() +}) + +describe('loadFieldTypes', () => { + it('loads each script URL once, even across two runs and two types', async () => { + const shared = asset('js/shared-once.js?v=111111111111') + const own = asset('js/own-once.js?v=222222222222') + const calls = mockApi(types({ data: [entry('alpha', [shared]), entry('beta', [shared, own])], meta: {} })) + const first = await loadFieldTypes() + const second = await loadFieldTypes() + expect(first.map((t) => t.name)).toEqual(['alpha', 'beta']) + expect(second).toEqual(first) + expect(calls.map((request) => new URL(request.url).pathname)).toEqual([`${API}/field-types`, `${API}/field-types`]) + expect(scripts(shared)).toHaveLength(1) + expect(scripts(own)).toHaveLength(1) + expect(scripts(shared)[0]!.type).toBe('module') + + scripts(shared)[0]!.dispatchEvent(new Event('load')) + scripts(own)[0]!.dispatchEvent(new Event('load')) + await expect(fieldTypeScripts('acme-demo-alpha')).resolves.toEqual([]) + await expect(fieldTypeScripts('acme-demo-beta')).resolves.toEqual([]) + }) + + it('does not wait for the scripts, and reports the ones that failed', async () => { + const ok = asset('js/ok-fail.js') + const broken = asset('js/broken-fail.js') + mockApi(types({ data: [entry('gamma', [ok, broken])], meta: {} })) + const loaded = await loadFieldTypes() + expect(loaded).toHaveLength(1) + let settled = false + const pending = fieldTypeScripts('acme-demo-gamma').then((failed) => { + settled = true + return failed + }) + await Promise.resolve() + expect(settled).toBe(false) + scripts(ok)[0]!.dispatchEvent(new Event('load')) + scripts(broken)[0]!.dispatchEvent(new Event('error')) + await expect(pending).resolves.toEqual([broken]) + }) + + it('refuses URLs outside the asset prefix without appending them', async () => { + const outside = 'https://cdn.example.test/admin-test/assets/acme/demo/js/x.js' + const climb = '/admin-test/assets/../api/v1/x.js' + const style = 'https://cdn.example.test/x.css' + mockApi(types({ data: [entry('delta', [outside, climb], [style])], meta: {} })) + await loadFieldTypes() + expect(sandbox.querySelectorAll('script')).toHaveLength(0) + expect(links(style)).toHaveLength(0) + await expect(fieldTypeScripts('acme-demo-delta')).resolves.toEqual([outside, climb]) + }) + + it('keeps field type styles enabled on every screen', async () => { + const global = asset('css/global-type.css') + const own = asset('css/controller-only.css') + mockApi(types({ data: [entry('epsilon', [], [global])], meta: {} })) + await loadFieldTypes() + loadStyles('acme.demo.widgets', [own]) + const [globalLink] = links(global) + const [ownLink] = links(own) + expect(globalLink!.getAttribute(OWNER_ATTRIBUTE)).toBe(GLOBAL_STYLE_OWNER) + expect(globalLink!.rel).toBe('stylesheet') + for (const active of ['acme.demo.widgets', 'acme.demo.other', '']) { + activateStyles(active) + expect(globalLink!.disabled).toBe(false) + expect(ownLink!.disabled).toBe(active !== 'acme.demo.widgets') + } + // A second load adds no second link. + await loadFieldTypes() + expect(links(global)).toHaveLength(1) + }) + + it.each<[string, () => void]>([ + ['a 500 answer', () => mockApi({ [`GET ${API}/field-types`]: { status: 500, body: { error: { code: 'error', message: 'down', details: {} } } } })], + ['a 401 answer', () => mockApi({ [`GET ${API}/field-types`]: { status: 401, body: { error: { code: 'unauthenticated', message: 'no', details: {} } } } })], + ['a network failure', () => vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network down'))], + ])('resolves an empty list on %s', async (_name, setup) => { + setup() + await expect(loadFieldTypes()).resolves.toEqual([]) + expect(sandbox.querySelectorAll('script, link')).toHaveLength(0) + }) + + it('tolerates a type without script or style lists', async () => { + mockApi(types({ data: [{ name: 'zeta', element: 'acme-demo-zeta', translatable: true }], meta: {} })) + const loaded = await loadFieldTypes() + expect(loaded.map((t) => t.name)).toEqual(['zeta']) + await expect(fieldTypeScripts('acme-demo-zeta')).resolves.toEqual([]) + }) +}) + +describe('fieldTypeScripts', () => { + it('resolves at once with no failures for an element no type defines', async () => { + await expect(fieldTypeScripts('acme-demo-unknown')).resolves.toEqual([]) + }) + + it('forgets the loaded types after resetFieldTypes', async () => { + const url = asset('js/reset.js') + mockApi(types({ data: [entry('eta', [url])], meta: {} })) + await loadFieldTypes() + scripts(url)[0]!.dispatchEvent(new Event('error')) + await expect(fieldTypeScripts('acme-demo-eta')).resolves.toEqual([url]) + resetFieldTypes() + await expect(fieldTypeScripts('acme-demo-eta')).resolves.toEqual([]) + }) +}) diff --git a/admin/tests/form/FormField.test.ts b/admin/tests/form/FormField.test.ts index 5a5fc9e..16fff72 100644 --- a/admin/tests/form/FormField.test.ts +++ b/admin/tests/form/FormField.test.ts @@ -5,6 +5,7 @@ import FormErrorBanner from '../../src/components/form/FormErrorBanner.vue' import FormField from '../../src/components/form/FormField.vue' import { clone, extensionFormSchemaFixture, formField } from '../fixtures/typed' import { mockApi, resetState } from '../helpers' +import type { FormField as FormFieldSchema } from '../../src/api/types' beforeEach(() => { resetState() @@ -32,6 +33,40 @@ describe('save notices', () => { }) }) +describe('plugin field rows (Phase 16.2)', () => { + const plugin: FormFieldSchema = { name: 'content', type: 'blogeditor', element: 'acme-demo-never-defined', label: 'Content', comment: 'Edited in the plugin element' } + + it('labels the plugin element group with a span, never a label for an input', () => { + const wrapper = mount(FormField, { props: { field: plugin, modelValue: '', idPrefix: 'f' } }) + expect(wrapper.find('label').exists()).toBe(false) + expect(wrapper.find('span#f-content-label').text()).toBe('Content') + const group = wrapper.find('[data-plugin-field]') + expect(group.attributes('aria-labelledby')).toBe('f-content-label') + expect(group.attributes('aria-describedby')).toBe('f-content-comment') + }) + + it('renders plugin notices as literal text linked to the group', () => { + const wrapper = mount(FormField, { + props: { field: plugin, modelValue: '', notices: ['EN: 1 element was removed: