html-bootstrap.client.spec.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
  1. // @vitest-environment jsdom
  2. /** Bootstrap serialization and resource creation in the receiving document's environment. */
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { runInNewContext } from 'node:vm'
  5. import { createHtmlDocument } from '../src/client/html/bootstrap.ts'
  6. import { decodeText, encodeText } from '../src/client/html/bytes.ts'
  7. afterEach(() => { vi.restoreAllMocks() })
  8. function run(html: string) {
  9. const open = vi.fn()
  10. const write = vi.fn<(html: string) => void>()
  11. const close = vi.fn()
  12. const createObjectURL = vi.fn<(blob: Blob) => string>().mockImplementation(() => `blob:null/resource-${createObjectURL.mock.calls.length}`)
  13. const script = html.slice(html.indexOf('<script>') + '<script>'.length, html.lastIndexOf('</script>'))
  14. runInNewContext(script, {
  15. document: { open, write, close }, URL: { createObjectURL }, Blob, DOMParser, atob, TextDecoder,
  16. })
  17. return { open, write, close, createObjectURL, document: new DOMParser().parseFromString(write.mock.calls[0]![0], 'text/html') }
  18. }
  19. const utf8 = (text: string): Uint8Array<ArrayBuffer> => new TextEncoder().encode(text)
  20. describe('HTML bootstrap', () => {
  21. it('carries UTF-8 and script-ending text as data, then writes the unchanged complete HTML', () => {
  22. const source = '<!doctype html><html lang="zh"><body>你好<script>window.value="</script><p>文本</p>"</script></body></html>'
  23. const html = createHtmlDocument({ data: utf8(source), assets: [] })
  24. expect(html.match(/<\/script>/gu)).toHaveLength(1)
  25. expect(html).not.toContain('你好')
  26. const result = run(html)
  27. expect(result.write).toHaveBeenCalledExactlyOnceWith(source)
  28. expect(result.open).toHaveBeenCalledOnce()
  29. expect(result.close).toHaveBeenCalledOnce()
  30. expect(result.createObjectURL).not.toHaveBeenCalled()
  31. })
  32. it('creates local resources in the frame and preserves base, inline code and script order attributes', () => {
  33. const source = '<!doctype html><html lang="en"><head><base href="https://example.invalid/assets/"><link rel="stylesheet" href="./main.css"></head><body><script src="./one.js"></script><script>window.next=1</script><script defer src="./one.js"></script><script async src="https://example.invalid/remote.js"></script></body></html>'
  34. const result = run(createHtmlDocument({ data: utf8(source), assets: [
  35. { kind: 'script', reference: './one.js', data: utf8('window.first=1') },
  36. { kind: 'stylesheet', reference: './main.css', data: utf8('body{color:red}') },
  37. ] }))
  38. expect(result.createObjectURL).toHaveBeenCalledTimes(2)
  39. expect(result.createObjectURL.mock.calls.map(([blob]) => blob.type)).toEqual(['application/javascript', 'text/css'])
  40. const scripts = [...result.document.querySelectorAll('script')]
  41. expect(scripts.map(script => script.getAttribute('src'))).toEqual(['blob:null/resource-1', null, 'blob:null/resource-1', 'https://example.invalid/remote.js'])
  42. expect(scripts[1]?.textContent).toBe('window.next=1')
  43. expect(scripts[2]?.defer).toBe(true)
  44. expect(scripts[3]?.hasAttribute('async')).toBe(true)
  45. expect(result.document.querySelector('link')?.getAttribute('href')).toBe('blob:null/resource-2')
  46. expect(result.document.querySelector('base')?.getAttribute('href')).toBe('https://example.invalid/assets/')
  47. expect(result.document.documentElement.lang).toBe('en')
  48. })
  49. it('rejects non-UTF-8 root and asset bytes before creating an iframe document', () => {
  50. expect(() => createHtmlDocument({ data: utf8('<p>root</p>'), assets: [{ kind: 'script', reference: 'bad.js', data: new Uint8Array([255]) }] })).toThrow()
  51. expect(() => createHtmlDocument({ data: new Uint8Array([255]), assets: [] })).toThrow()
  52. expect(decodeText(utf8('雪\u2028\u2029'))).toBe('雪\u2028\u2029')
  53. })
  54. it('base64-encodes a large UTF-8 payload in browser-safe chunks', () => {
  55. const source = `${'0123456789abcdef'.repeat(16_384)}雪`
  56. const bytes = Uint8Array.from(atob(encodeText(source)), character => character.charCodeAt(0))
  57. expect(decodeText(bytes)).toBe(source)
  58. })
  59. })