html-read-relative.client.spec.ts 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
  1. /** HTML URL decoding stays local; ordinary Remote reads leave path resolution and authorization to the Host. */
  2. import { describe, expect, it, vi } from 'vitest'
  3. import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  4. import { createReadHtmlRelative } from '../src/client/html/read-relative.ts'
  5. import type { ReadHtmlRelated } from '../src/client/html/read-relative.ts'
  6. const ADDRESS = 'dsh-resource://file/session/html/sub/index.html'
  7. describe('HTML relative file reader', () => {
  8. it('decodes a relative URL once and converts the Remote result to native bytes', async () => {
  9. const value = { absolutePath: '/workspace/a b.js', data: btoa('x'), version: 'v1', offset: 0, bytes: 1, eof: true }
  10. const readRelated = vi.fn<ReadHtmlRelated>().mockResolvedValue({ ok: true, value })
  11. const tab = new AbortController()
  12. const loading = new AbortController()
  13. const read = createReadHtmlRelative(readRelated, ADDRESS, tab.signal)
  14. await expect(read('../a%20b.js?v=1#fragment', loading.signal)).resolves.toEqual({ ...value, data: new Uint8Array([120]) })
  15. const signal = readRelated.mock.calls[0]?.[2]
  16. expect(readRelated).toHaveBeenCalledExactlyOnceWith(ADDRESS, '../a b.js', signal)
  17. expect(signal?.aborted).toBe(false)
  18. tab.abort()
  19. expect(signal?.aborted).toBe(true)
  20. })
  21. it('refuses non-relative references and preserves Host permission failures', async () => {
  22. const readRelated = vi.fn<ReadHtmlRelated>().mockResolvedValue({
  23. ok: false, error: new RemoteError('workspace-file/outside-workspace', 'outside workspace', { path: '../x.js' }),
  24. })
  25. const signal = new AbortController().signal
  26. const read = createReadHtmlRelative(readRelated, ADDRESS, signal)
  27. for (const path of ['', '/x.js', 'file:///x.js', '%2Fx.js', 'C:/x.js', '..\\x.js', '%00.js', '%ZZ.js']) {
  28. await expect(read(path, signal)).rejects.toThrow()
  29. }
  30. expect(readRelated).not.toHaveBeenCalled()
  31. await expect(read('../x.js', signal)).rejects.toThrow('outside workspace')
  32. })
  33. it('does not start an already cancelled read', async () => {
  34. const controller = new AbortController()
  35. controller.abort()
  36. const readRelated = vi.fn<ReadHtmlRelated>()
  37. const read = createReadHtmlRelative(readRelated, ADDRESS, controller.signal)
  38. await expect(read('x.js', new AbortController().signal)).rejects.toMatchObject({ name: 'AbortError' })
  39. expect(readRelated).not.toHaveBeenCalled()
  40. })
  41. it('rejects bytes that arrive after this packing request is cancelled', async () => {
  42. const pending = Promise.withResolvers<Awaited<ReturnType<ReadHtmlRelated>>>()
  43. const readRelated = vi.fn<ReadHtmlRelated>().mockReturnValue(pending.promise)
  44. const loading = new AbortController()
  45. const read = createReadHtmlRelative(readRelated, ADDRESS, new AbortController().signal)
  46. const result = read('./late.js', loading.signal)
  47. const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' })
  48. loading.abort()
  49. pending.resolve({ ok: true, value: { absolutePath: '/workspace/late.js', version: 'v1', bytes: 1, offset: 0, data: 'AQ==', eof: true } })
  50. await rejected
  51. })
  52. })