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.
223 lines
8.8 KiB
TypeScript
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)
|
|
})
|
|
})
|