html-pack.client.spec.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. // @vitest-environment jsdom
  2. /** Static dependency discovery has an injected file reader and never exposes it to the iframe. */
  3. import { describe, expect, it, vi } from 'vitest'
  4. import { packHtml } from '../src/client/html/pack.ts'
  5. import type { ReadHtmlRelative } from '../src/client/html/pack.ts'
  6. import type { DocumentFileBytes } from '../src/client/rpc.ts'
  7. const source = '<link rel="stylesheet" href="./main.css"><script src="./main.js"></script>'
  8. const utf8 = (text: string): Uint8Array<ArrayBuffer> => new TextEncoder().encode(text)
  9. const file = (data: Uint8Array<ArrayBuffer>): DocumentFileBytes => ({
  10. absolutePath: '/workspace/asset', version: 'v1', offset: 0, data, bytes: data.byteLength, eof: true,
  11. })
  12. describe('packHtml', () => {
  13. it('collects direct classic JS and CSS in document order and deduplicates repeated references', async () => {
  14. const read = vi.fn<ReadHtmlRelative>().mockResolvedValue(file(utf8('/* 你好 */')))
  15. const signal = new AbortController().signal
  16. const bundle = await packHtml(utf8(source + '<script defer src="./main.js"></script>'), read, signal)
  17. expect(read.mock.calls).toEqual([['./main.css', signal], ['./main.js', signal]])
  18. expect(bundle.assets.map(asset => [asset.kind, asset.reference])).toEqual([['stylesheet', './main.css'], ['script', './main.js']])
  19. expect(document.querySelector('script,link')).toBeNull()
  20. })
  21. it('leaves HTTPS, module, file, root-relative, data and runtime dependencies to browser rules', async () => {
  22. const read = vi.fn<ReadHtmlRelative>()
  23. const html = '<script src="https://example.invalid/a.js"></script><script src="//example.invalid/a.js"></script><script type="module" src="./module.js"></script><script type="application/ld+json" src="./data.js"></script><script src="file:///a.js"></script><script src="/a.js"></script><script src="data:text/javascript,1"></script><script>fetch("./data.json")</script><link rel="icon" href="./icon.css"><!-- <script src="./comment.js"></script> -->'
  24. expect((await packHtml(utf8(html), read, new AbortController().signal)).assets).toEqual([])
  25. expect(read).not.toHaveBeenCalled()
  26. })
  27. it('does not turn base-relative browser resources into local file reads', async () => {
  28. const read = vi.fn<ReadHtmlRelative>()
  29. for (const base of ['https://example.invalid/assets/', './assets/', 'file:///assets/']) {
  30. const bundle = await packHtml(utf8(`<base href="${base}">${source}`), read, new AbortController().signal)
  31. expect(bundle.assets).toEqual([])
  32. }
  33. expect(read).not.toHaveBeenCalled()
  34. })
  35. it('does not read a link without a stylesheet relationship', async () => {
  36. const read = vi.fn<ReadHtmlRelative>()
  37. const bundle = await packHtml(utf8('<link href="./main.css">'), read, new AbortController().signal)
  38. expect(bundle.assets).toEqual([])
  39. expect(read).not.toHaveBeenCalled()
  40. })
  41. it('passes decoded HTML attributes to the scoped reader without recursing into CSS imports', async () => {
  42. const read = vi.fn<ReadHtmlRelative>().mockResolvedValue(file(utf8('@import "./child.css";a{background:url(./image.png)}')))
  43. const bundle = await packHtml(utf8('<link rel="STYLESHEET" href="main.css?v=1&amp;x=2">'), read, new AbortController().signal)
  44. expect(read.mock.calls[0]?.[0]).toBe('main.css?v=1&x=2')
  45. expect(read).toHaveBeenCalledOnce()
  46. expect(bundle.assets).toHaveLength(1)
  47. })
  48. it('accepts the fixed per-asset limit and rejects oversized roots and assets', async () => {
  49. const mebibyte = 1024 * 1024
  50. const html = utf8('<script src="a.js"></script>')
  51. const read = vi.fn<ReadHtmlRelative>().mockResolvedValue(file(new Uint8Array(4 * mebibyte)))
  52. const signal = new AbortController().signal
  53. await expect(packHtml(html, read, signal)).resolves.toMatchObject({ data: html })
  54. await expect(packHtml(new Uint8Array(32 * mebibyte + 1), read, signal)).rejects.toThrow('total byte limit')
  55. read.mockResolvedValue(file(new Uint8Array(4 * mebibyte + 1)))
  56. await expect(packHtml(html, read, signal)).rejects.toThrow('asset exceeds')
  57. })
  58. it('rejects fixed aggregate and asset-count limits', async () => {
  59. const mebibyte = 1024 * 1024
  60. const aggregate = Array.from({ length: 8 }, (_, index) => `<script src="${index}.js"></script>`).join('')
  61. const read = vi.fn<ReadHtmlRelative>().mockResolvedValue(file(new Uint8Array(4 * mebibyte)))
  62. await expect(packHtml(utf8(aggregate), read, new AbortController().signal)).rejects.toThrow('total byte limit')
  63. const count = Array.from({ length: 65 }, (_, index) => `<script src="${index}.js"></script>`).join('')
  64. read.mockResolvedValue(file(new Uint8Array()))
  65. await expect(packHtml(utf8(count), read, new AbortController().signal)).rejects.toThrow('asset count limit')
  66. expect(read).toHaveBeenCalledTimes(8 + 64)
  67. })
  68. it('propagates read errors and rejects malformed resource text instead of returning a partial package', async () => {
  69. const read = vi.fn<ReadHtmlRelative>().mockRejectedValue(new Error('outside workspace'))
  70. await expect(packHtml(utf8(source), read, new AbortController().signal)).rejects.toThrow('outside workspace')
  71. read.mockResolvedValue(file(new Uint8Array([255])))
  72. await expect(packHtml(utf8(source), read, new AbortController().signal)).rejects.toThrow()
  73. })
  74. it('does not read after abort and discards a read that settles after cancellation', async () => {
  75. const pending = Promise.withResolvers<DocumentFileBytes>()
  76. const read = vi.fn<ReadHtmlRelative>().mockReturnValue(pending.promise)
  77. const controller = new AbortController()
  78. const packing = packHtml(utf8(source), read, controller.signal)
  79. expect(read).toHaveBeenCalledOnce()
  80. const rejected = expect(packing).rejects.toMatchObject({ name: 'AbortError' })
  81. controller.abort()
  82. pending.resolve(file(utf8('body{}')))
  83. await rejected
  84. await expect(packHtml(utf8(source), read, controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
  85. expect(read).toHaveBeenCalledOnce()
  86. })
  87. })