markdown-path-images.client.spec.tsx 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. // @vitest-environment jsdom
  2. import { cleanup, fireEvent, render, screen } from '@testing-library/react'
  3. import { afterEach, describe, expect, it } from 'vitest'
  4. import { MarkdownText } from './markdown-test-components.tsx'
  5. import type { MarkdownPathImages } from '../src/markdown/MarkdownText.tsx'
  6. afterEach(cleanup)
  7. const LOCAL_IMAGE = '![diagram](/tmp/graph.png)'
  8. const mapping = (value: string): string | undefined =>
  9. value === '/tmp/graph.png' ? 'https://cdn.example.com/graph.png' : undefined
  10. describe('MarkdownText local-path images', () => {
  11. it('renders authored alt text when no path vocabulary exists', () => {
  12. const { container } = render(<MarkdownText text={LOCAL_IMAGE} />)
  13. expect(container.querySelector('img')).toBeNull()
  14. expect(screen.getByText('diagram')).toBeTruthy()
  15. })
  16. it('rewrites a local image path through the vocabulary', () => {
  17. const pathImages: MarkdownPathImages = { resolve: mapping }
  18. const { container } = render(<MarkdownText text={LOCAL_IMAGE} pathImages={pathImages} />)
  19. const image = container.querySelector('img')
  20. expect(image?.getAttribute('src')).toBe('https://cdn.example.com/graph.png')
  21. expect(image?.getAttribute('alt')).toBe('diagram')
  22. })
  23. it.each(['diagram', ''])('shows authored text after an image fails with alt %j', (alt) => {
  24. const pathImages: MarkdownPathImages = { resolve: mapping }
  25. const { container } = render(
  26. <MarkdownText text={`![${alt}](/tmp/graph.png)`} pathImages={pathImages} />,
  27. )
  28. fireEvent.error(screen.getByAltText(alt))
  29. expect(container.querySelector('img')).toBeNull()
  30. expect(container.textContent).toBe(alt || '/tmp/graph.png')
  31. })
  32. it('loads a replacement destination after the preceding image failed', () => {
  33. const pathImages: MarkdownPathImages = { resolve: value => `https://example.com${value}` }
  34. const { container, rerender } = render(
  35. <MarkdownText text={LOCAL_IMAGE} pathImages={pathImages} />,
  36. )
  37. fireEvent.error(screen.getByRole('img'))
  38. rerender(<MarkdownText text="![](/tmp/replacement.png)" pathImages={pathImages} />)
  39. expect(container.querySelector('img')?.getAttribute('src'))
  40. .toBe('https://example.com/tmp/replacement.png')
  41. })
  42. it('keeps the alt fallback when the vocabulary misses', () => {
  43. const pathImages: MarkdownPathImages = { resolve: () => undefined }
  44. const { container } = render(<MarkdownText text={LOCAL_IMAGE} pathImages={pathImages} />)
  45. expect(container.querySelector('img')).toBeNull()
  46. expect(screen.getByText('diagram')).toBeTruthy()
  47. })
  48. it('accepts data and blob vocabulary results', () => {
  49. const data: MarkdownPathImages = { resolve: () => 'data:image/png;base64,AAAA' }
  50. const blob: MarkdownPathImages = { resolve: () => 'blob:https://example.com/id' }
  51. const dataRender = render(<MarkdownText text={LOCAL_IMAGE} pathImages={data} />)
  52. const blobRender = render(<MarkdownText text={LOCAL_IMAGE} pathImages={blob} />)
  53. expect(dataRender.container.querySelector('img')?.getAttribute('src'))
  54. .toBe('data:image/png;base64,AAAA')
  55. expect(blobRender.container.querySelector('img')?.getAttribute('src'))
  56. .toBe('blob:https://example.com/id')
  57. })
  58. it('rejects non-absolute vocabulary results', () => {
  59. for (const result of ['relative.png', '/api/image?path=%2Ftmp%2Fx.png', 'ftp://host/x.png']) {
  60. const pathImages: MarkdownPathImages = { resolve: () => result }
  61. const { container, unmount } = render(<MarkdownText text={LOCAL_IMAGE} pathImages={pathImages} />)
  62. expect(container.querySelector('img')).toBeNull()
  63. unmount()
  64. }
  65. })
  66. it('leaves remote images untouched even when the vocabulary maps them', () => {
  67. const remote = '![remote](https://example.com/a.png)'
  68. const pathImages: MarkdownPathImages = { resolve: () => 'https://cdn.example.com/b.png' }
  69. const { container } = render(<MarkdownText text={remote} pathImages={pathImages} />)
  70. expect(container.querySelector('img')?.getAttribute('src')).toBe('https://example.com/a.png')
  71. })
  72. it('rewrites reference-style local image destinations', () => {
  73. const reference = ['![diagram][fig]', '', '[fig]: /tmp/graph.png'].join('\n')
  74. const pathImages: MarkdownPathImages = { resolve: mapping }
  75. const { container } = render(<MarkdownText text={reference} pathImages={pathImages} />)
  76. expect(container.querySelector('img')?.getAttribute('src'))
  77. .toBe('https://cdn.example.com/graph.png')
  78. })
  79. it('applies the vocabulary only to settled renders, never while streaming', () => {
  80. const pathImages: MarkdownPathImages = { resolve: mapping }
  81. const { container, rerender } = render(
  82. <MarkdownText text={LOCAL_IMAGE} streaming pathImages={pathImages} />,
  83. )
  84. // Streaming messages may still grow, so their prose keeps the inert alt
  85. // fallback until the settled pass self-heals it.
  86. expect(container.querySelector('img')).toBeNull()
  87. rerender(<MarkdownText text={LOCAL_IMAGE} pathImages={pathImages} />)
  88. expect(container.querySelector('img')?.getAttribute('src'))
  89. .toBe('https://cdn.example.com/graph.png')
  90. })
  91. })