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
This commit is contained in:
2026-10-10 16:03:07 +02:00
parent 3404a4b3ad
commit 9f5e807af6
6 changed files with 322 additions and 0 deletions

View File

@@ -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(<T extends Node>(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<string, Route> {
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([])
})
})