Files
summercms/admin/tests/app/pluginAssets.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

223 lines
8.8 KiB
TypeScript

// Plugin asset loader (D-14, D-16; T-10.1-13, T-10.1-16). 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.
// Module state (loaded URLs, owned links) lives for the whole file, so every
// test uses its own URLs and controller ids.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
activateStyles,
assetAllowed,
assetPrefix,
loadControllerAssets,
loadScript,
loadStyles,
OWNER_ATTRIBUTE,
} from '../../src/app/pluginAssets'
let sandbox: HTMLDivElement
/** Redirects head.appendChild of scripts and links into the sandbox. */
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(owner: string): HTMLLinkElement[] {
return Array.from(sandbox.querySelectorAll('link')).filter((link) => link.getAttribute(OWNER_ATTRIBUTE) === owner)
}
function fire(script: HTMLScriptElement | undefined, type: 'load' | 'error'): void {
if (!script) {
throw new Error('script was not appended')
}
script.dispatchEvent(new Event(type))
}
const asset = (file: string) => `/admin-test/assets/acme/demo/${file}`
beforeEach(() => {
captureHead()
})
afterEach(() => {
sandbox.remove()
})
describe('asset URL check', () => {
it('uses the runtime base', () => {
expect(assetPrefix()).toBe('/admin-test/assets/')
})
it.each([
asset('js/lookup.js?v=0123456789ab'),
asset('css/gadgets.css'),
asset('js/nested/deep.mjs#frag'),
])('accepts %s', (url) => {
expect(assetAllowed(url)).toBe(true)
})
it.each([
['another origin', 'https://cdn.example.test/admin-test/assets/x.js'],
['protocol-relative', '//cdn.example.test/admin-test/assets/x.js'],
['another prefix', '/admin-test/api/v1/acme/demo/widgets'],
['the prefix as a substring', '/admin-testassets/x.js'],
['a sibling base', '/admin-test2/assets/x.js'],
['a relative path', 'assets/x.js'],
['a scheme', 'javascript:alert(1)'],
['a dot-dot segment', '/admin-test/assets/../api/v1/x.js'],
['a dot segment', '/admin-test/assets/./x.js'],
['an encoded dot-dot segment', '/admin-test/assets/%2e%2e/api/v1/x.js'],
['a mixed-case encoded dot-dot segment', '/admin-test/assets/.%2E/api/v1/x.js'],
['an encoded dot segment', '/admin-test/assets/%2E/x.js'],
['a backslash', '/admin-test/assets/a\\..\\x.js'],
['a space', '/admin-test/assets/a b.js'],
['a tab', '/admin-test/assets/\t/x.js'],
['a newline', '/admin-test/assets/x.js\n'],
['a DEL', '/admin-test/assets/x\u007f.js'],
['empty', ''],
])('refuses %s', (_, url) => {
expect(assetAllowed(url)).toBe(false)
})
})
describe('loadScript', () => {
it('refuses a URL outside the prefix without touching the document', async () => {
await expect(loadScript('https://cdn.example.test/x.js')).rejects.toThrow('outside /admin-test/assets/')
await expect(loadScript('/admin-test/assets/%2e%2e/api/v1/x.js')).rejects.toThrow()
expect(sandbox.children).toHaveLength(0)
})
it('appends one module script per URL and shares its promise', async () => {
const url = asset('js/once.js?v=1')
const first = loadScript(url)
const second = loadScript(url)
expect(second).toBe(first)
const [script, ...extra] = scripts(url)
expect(extra).toHaveLength(0)
expect(script!.type).toBe('module')
expect(script!.getAttribute('src')).toBe(url)
fire(script, 'load')
await expect(first).resolves.toBeUndefined()
// A loaded URL stays loaded: no second element, the same promise.
expect(loadScript(url)).toBe(first)
expect(scripts(url)).toHaveLength(1)
})
it('rejects on an error, forgets the URL and appends a new element on retry', async () => {
const url = asset('js/flaky.js?v=1')
const failed = loadScript(url)
const [broken] = scripts(url)
fire(broken, 'error')
await expect(failed).rejects.toThrow(`plugin script failed to load: ${url}`)
expect(scripts(url)).toHaveLength(0)
const retry = loadScript(url)
expect(retry).not.toBe(failed)
const [again] = scripts(url)
expect(again).toBeDefined()
expect(again).not.toBe(broken)
fire(again, 'load')
await expect(retry).resolves.toBeUndefined()
})
})
describe('controller stylesheets', () => {
it('tags each link with its controller, once per controller and URL', () => {
const css = asset('css/p1.css?v=1')
loadStyles('acme.demo.p1', [css, css, 'https://cdn.example.test/x.css', '/admin-test/assets/../x.css'])
loadStyles('acme.demo.p1', [css])
const [link, ...extra] = links('acme.demo.p1')
expect(extra).toHaveLength(0)
expect(link!.rel).toBe('stylesheet')
expect(link!.getAttribute('href')).toBe(css)
expect(OWNER_ATTRIBUTE).toBe('data-summer-controller')
// Another controller of the same plugin gets its own link to the file.
loadStyles('acme.demo.p2', [css])
expect(links('acme.demo.p2')).toHaveLength(1)
expect(links('acme.demo.p2')[0]).not.toBe(link)
})
it('enables the opened controller and disables every other one', () => {
loadStyles('acme.demo.s1', [asset('css/s1.css'), asset('css/shared.css')])
loadStyles('acme.demo.s2', [asset('css/shared.css')])
activateStyles('acme.demo.s2')
expect(links('acme.demo.s1').map((link) => link.disabled)).toEqual([true, true])
expect(links('acme.demo.s2').map((link) => link.disabled)).toEqual([false])
activateStyles('acme.demo.s1')
expect(links('acme.demo.s1').map((link) => link.disabled)).toEqual([false, false])
expect(links('acme.demo.s2').map((link) => link.disabled)).toEqual([true])
activateStyles('acme.demo.none')
expect([...links('acme.demo.s1'), ...links('acme.demo.s2')].every((link) => link.disabled)).toBe(true)
})
})
describe('loadControllerAssets', () => {
it('creates the styles of the active controller enabled, then resolves once the scripts have loaded', async () => {
loadStyles('acme.demo.other', [asset('css/other.css')])
activateStyles('acme.demo.c1')
const js = asset('js/c1.js')
let settled = false
const done = loadControllerAssets('acme.demo.c1', { scripts: [js], styles: [asset('css/c1.css')] }).then((failed) => {
settled = true
return failed
})
expect(links('acme.demo.c1').map((link) => link.disabled)).toEqual([false])
expect(links('acme.demo.other').map((link) => link.disabled)).toEqual([true])
await Promise.resolve()
await Promise.resolve()
expect(settled).toBe(false)
fire(scripts(js)[0], 'load')
await expect(done).resolves.toEqual([])
expect(settled).toBe(true)
})
it('never enables a controller the router has left (a late schema response)', async () => {
activateStyles('acme.demo.late-b')
await loadControllerAssets('acme.demo.late-b', { scripts: [], styles: [asset('css/late-b.css')] })
expect(links('acme.demo.late-b').map((link) => link.disabled)).toEqual([false])
// A's schema resolves after the router moved on to B.
await loadControllerAssets('acme.demo.late-a', { scripts: [], styles: [asset('css/late-a.css')] })
expect(links('acme.demo.late-a').map((link) => link.disabled)).toEqual([true])
expect(links('acme.demo.late-b').map((link) => link.disabled)).toEqual([false])
// Links added while no controller is active stay disabled until one is.
activateStyles('')
loadStyles('acme.demo.late-c', [asset('css/late-c.css')])
expect(links('acme.demo.late-c').map((link) => link.disabled)).toEqual([true])
activateStyles('acme.demo.late-c')
expect(links('acme.demo.late-c').map((link) => link.disabled)).toEqual([false])
expect([...links('acme.demo.late-a'), ...links('acme.demo.late-b')].every((link) => link.disabled)).toBe(true)
})
it('resolves with the scripts that failed or were refused and never rejects', async () => {
const ok = asset('js/c2-ok.js')
const bad = asset('js/c2-bad.js')
const foreign = 'https://cdn.example.test/x.js'
const done = loadControllerAssets('acme.demo.c2', { scripts: [ok, bad, foreign], styles: [] })
fire(scripts(ok)[0], 'load')
fire(scripts(bad)[0], 'error')
await expect(done).resolves.toEqual([bad, foreign])
})
it('treats missing assets as nothing to load', async () => {
await expect(loadControllerAssets('acme.demo.c3', null)).resolves.toEqual([])
await expect(loadControllerAssets('acme.demo.c3', undefined)).resolves.toEqual([])
expect(sandbox.children).toHaveLength(0)
})
})