html-body.client.spec.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. // @vitest-environment jsdom
  2. /** HTML iframe ownership follows file identity and bytes, not locale or wrapping changes. */
  3. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  4. import { act, cleanup, render, screen } from '@testing-library/react'
  5. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  6. import { HtmlBody } from '../src/client/html/HtmlBody.tsx'
  7. import type { HtmlBodyProps } from '../src/client/html/HtmlBody.tsx'
  8. import { en } from '../src/client/html/locales.ts'
  9. const translations: ReadonlyMap<string, string> = new Map(Object.entries(en))
  10. let createDescriptor: PropertyDescriptor | undefined
  11. let revokeDescriptor: PropertyDescriptor | undefined
  12. const create = vi.fn<(blob: Blob) => string>()
  13. const revoke = vi.fn<(url: string) => void>()
  14. beforeEach(() => {
  15. createDescriptor = Object.getOwnPropertyDescriptor(URL, 'createObjectURL')
  16. revokeDescriptor = Object.getOwnPropertyDescriptor(URL, 'revokeObjectURL')
  17. create.mockReset().mockImplementation(() => `blob:https://preview.invalid/${create.mock.calls.length}`)
  18. revoke.mockReset()
  19. Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: create })
  20. Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: revoke })
  21. })
  22. afterEach(() => {
  23. try { cleanup() } finally {
  24. if (createDescriptor === undefined) Reflect.deleteProperty(URL, 'createObjectURL')
  25. else Object.defineProperty(URL, 'createObjectURL', createDescriptor)
  26. if (revokeDescriptor === undefined) Reflect.deleteProperty(URL, 'revokeObjectURL')
  27. else Object.defineProperty(URL, 'revokeObjectURL', revokeDescriptor)
  28. }
  29. })
  30. function props(text = '<p>hello</p>'): HtmlBodyProps {
  31. const signal = new AbortController().signal
  32. return {
  33. resourceAddress: 'dsh-resource://file/session/html/index.html',
  34. content: { kind: 'bytes', data: utf8(text) },
  35. wrap: false,
  36. sessionId: 'html' as SessionId,
  37. useTabInfo: () => ({ tab: { signal } }),
  38. readRelated: vi.fn(),
  39. useResource: () => ({ value: undefined }),
  40. t: key => translations.get(key) ?? key,
  41. } as HtmlBodyProps
  42. }
  43. const utf8 = (text: string): Uint8Array<ArrayBuffer> => new TextEncoder().encode(text)
  44. describe('HtmlBody', () => {
  45. it('renders a Blob iframe with only scripts allowed, keeping it mounted for unrelated props', async () => {
  46. const initial = props()
  47. const view = render(<HtmlBody {...initial} />)
  48. const iframe = await screen.findByTitle(en.frame)
  49. expect(iframe.getAttribute('sandbox')).toBe('allow-scripts')
  50. expect(iframe.getAttribute('src')).toBe('blob:https://preview.invalid/1')
  51. expect(create.mock.calls[0]?.[0].type).toBe('text/html')
  52. view.rerender(<HtmlBody {...initial} wrap />)
  53. expect(screen.getByTitle(en.frame)).toBe(iframe)
  54. expect(create).toHaveBeenCalledOnce()
  55. expect(revoke).not.toHaveBeenCalled()
  56. view.unmount()
  57. expect(revoke).toHaveBeenCalledExactlyOnceWith('blob:https://preview.invalid/1')
  58. })
  59. it('destroys the old frame and revokes its Blob when bytes or source file change', async () => {
  60. const view = render(<HtmlBody {...props()} />)
  61. const first = await screen.findByTitle(en.frame)
  62. const changed = props('<p>changed</p>')
  63. view.rerender(<HtmlBody {...changed} />)
  64. expect(await screen.findByTitle(en.frame)).not.toBe(first)
  65. expect(first.isConnected).toBe(false)
  66. expect(revoke).toHaveBeenCalledWith('blob:https://preview.invalid/1')
  67. view.rerender(<HtmlBody {...changed} resourceAddress="dsh-resource://file/session/html/other.html" />)
  68. await screen.findByTitle(en.frame)
  69. expect(revoke).toHaveBeenCalledWith('blob:https://preview.invalid/2')
  70. view.unmount()
  71. expect(revoke).toHaveBeenCalledWith('blob:https://preview.invalid/3')
  72. })
  73. it('reports invalid bytes and Blob creation failures without leaving a previous frame running', async () => {
  74. const view = render(<HtmlBody {...props()} />)
  75. await screen.findByTitle(en.frame)
  76. view.rerender(<HtmlBody {...props()} content={{ kind: 'bytes', data: new Uint8Array([255]) }} />)
  77. expect((await screen.findByRole('alert')).textContent).toBe(en.failed)
  78. expect(screen.queryByTitle(en.frame)).toBeNull()
  79. expect(revoke).toHaveBeenCalledWith('blob:https://preview.invalid/1')
  80. create.mockImplementationOnce(() => { throw new Error('Blob unavailable') })
  81. view.rerender(<HtmlBody {...props('different')} />)
  82. expect((await screen.findByRole('alert')).textContent).toBe(en.failed)
  83. })
  84. it('does not create a document for a text-pages delivery', () => {
  85. render(<HtmlBody {...props()} content={{ kind: 'text', text: 'plain', pages: [], eof: true }} />)
  86. expect(create).not.toHaveBeenCalled()
  87. expect(screen.queryByTitle(en.frame)).toBeNull()
  88. })
  89. it('reads related bytes through the injected callback and cancels pending reads on unmount', async () => {
  90. const pending = Promise.withResolvers<never>()
  91. const bytes = vi.fn().mockReturnValue(pending.promise)
  92. const signal = new AbortController().signal
  93. const initial = {
  94. ...props('<script src="./app.js"></script>'),
  95. useTabInfo: () => ({ tab: { signal } }),
  96. readRelated: bytes,
  97. } as unknown as HtmlBodyProps
  98. const view = render(<HtmlBody {...initial} />)
  99. expect(bytes).toHaveBeenCalledOnce()
  100. expect(bytes).toHaveBeenCalledWith(initial.resourceAddress, './app.js', expect.any(AbortSignal))
  101. const readSignal = bytes.mock.calls[0]?.[2] as AbortSignal
  102. view.unmount()
  103. expect(readSignal.aborted).toBe(true)
  104. await act(async () => { pending.reject(new Error('cancelled')) })
  105. expect(create).not.toHaveBeenCalled()
  106. })
  107. it('packages the declared local script without waiting for metadata', async () => {
  108. const signal = new AbortController().signal
  109. const bytes = vi.fn().mockResolvedValue({
  110. ok: true,
  111. value: { absolutePath: '/workspace/app.js', data: btoa('window.ready=true'), version: 'v1', offset: 0, eof: true },
  112. })
  113. const useResource = vi.fn().mockReturnValue({ value: undefined })
  114. const initial = {
  115. ...props('<script src="./app.js"></script>'),
  116. useTabInfo: () => ({ tab: { signal } }),
  117. useResource,
  118. readRelated: bytes,
  119. } as unknown as HtmlBodyProps
  120. render(<HtmlBody {...initial} />)
  121. expect(screen.getByRole('status').getAttribute('aria-label')).toBe(en.loading)
  122. expect(screen.getByRole('status').hasAttribute('data-document-loading')).toBe(true)
  123. expect(create).not.toHaveBeenCalled()
  124. expect(await screen.findByTitle(en.frame)).toBeTruthy()
  125. expect(useResource).not.toHaveBeenCalled()
  126. expect(bytes).toHaveBeenCalledOnce()
  127. expect(create).toHaveBeenCalledOnce()
  128. })
  129. it('retains the iframe when only observed metadata changes around the stable injected callback', async () => {
  130. const signal = new AbortController().signal
  131. const bytes = vi.fn().mockResolvedValue({
  132. ok: true,
  133. value: { absolutePath: '/workspace/app.js', version: 'v1', bytes: 16, offset: 0, data: btoa('window.ready=1'), eof: true },
  134. })
  135. const useResource = vi.fn(() => ({ value: { version: 'v1' } }))
  136. const initial = {
  137. ...props('<script src="./app.js"></script>'), useTabInfo: () => ({ tab: { signal } }),
  138. useResource, readRelated: bytes,
  139. } as unknown as HtmlBodyProps
  140. const view = render(<HtmlBody {...initial} />)
  141. const iframe = await screen.findByTitle(en.frame)
  142. useResource.mockReturnValue({ value: { version: 'v2' } })
  143. view.rerender(<HtmlBody {...initial} />)
  144. expect(screen.getByTitle(en.frame)).toBe(iframe)
  145. expect(bytes).toHaveBeenCalledOnce()
  146. expect(create).toHaveBeenCalledOnce()
  147. expect(revoke).not.toHaveBeenCalled()
  148. })
  149. })