// 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((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) }) })