read-render.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. /**
  2. * Cordis-free tests for the line-windowing module: offset/limit windows, byte
  3. * caps, per-line truncation, CRLF stripping, offset-past-EOF rejection, and the
  4. * capped line buffer for newline-free giant lines — all over an async-iterable
  5. * of decoded text chunks (so one code path serves whole-file and streamed reads).
  6. */
  7. import { describe, expect, it } from 'vitest'
  8. import { buildWindow, langFromPath, readMetaFromMeta, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts'
  9. import type { ReadWindow } from '../src/read-render.ts'
  10. const DEFAULT_CAPS = { maxLineLength: READ_MAX_LINE_LENGTH, maxBytes: READ_MAX_BYTES }
  11. const READ_ALL: ReadWindow = { offset: 1, limit: 2000, ...DEFAULT_CAPS }
  12. /** Yield `text` as one chunk (whole-file read shape). */
  13. async function* whole(text: string): AsyncIterable<string> {
  14. yield text
  15. }
  16. /** Yield `text` split into fixed-size chunks (streamed read shape). */
  17. async function* chunked(text: string, size: number): AsyncIterable<string> {
  18. for (let i = 0; i < text.length; i += size) yield text.slice(i, i + size)
  19. }
  20. describe('buildWindow', () => {
  21. it('numbers lines and reports total for a whole-file read', async () => {
  22. const result = await buildWindow(whole('one\ntwo\nthree'), READ_ALL, 'f')
  23. expect(result.lines).toEqual([
  24. { number: 1, text: 'one' },
  25. { number: 2, text: 'two' },
  26. { number: 3, text: 'three' },
  27. ])
  28. expect(result.totalLines).toBe(3)
  29. expect(result.truncatedByBytes).toBe(false)
  30. })
  31. it('applies offset/limit', async () => {
  32. const result = await buildWindow(whole('one\ntwo\nthree\nfour'), { offset: 2, limit: 2, ...DEFAULT_CAPS }, 'f')
  33. expect(result.lines.map(l => l.number)).toEqual([2, 3])
  34. expect(result.totalLines).toBe(4)
  35. })
  36. it('strips CRLF', async () => {
  37. const result = await buildWindow(whole('one\r\ntwo\r\n'), READ_ALL, 'f')
  38. expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
  39. })
  40. it('truncates an over-long line', async () => {
  41. const result = await buildWindow(whole('x'.repeat(3000)), READ_ALL, 'f')
  42. expect(result.lines[0]?.text).toContain(`... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`)
  43. })
  44. it('caps output bytes and reports truncatedByBytes', async () => {
  45. const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')
  46. const result = await buildWindow(whole(big), READ_ALL, 'f')
  47. expect(result.truncatedByBytes).toBe(true)
  48. })
  49. it('reads an empty file at offset 1 as zero lines', async () => {
  50. const result = await buildWindow(whole(''), READ_ALL, 'f')
  51. expect(result.lines).toEqual([])
  52. expect(result.totalLines).toBe(0)
  53. })
  54. it('rejects an offset past EOF', async () => {
  55. await expect(buildWindow(whole('one\ntwo'), { offset: 9, limit: 1, ...DEFAULT_CAPS }, 'f')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
  56. })
  57. it('flushes a final line with no trailing newline', async () => {
  58. const result = await buildWindow(whole('one\ntwo'), READ_ALL, 'f')
  59. expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
  60. })
  61. it('handles a trailing newline (no dangling empty line)', async () => {
  62. const result = await buildWindow(whole('one\ntwo\n'), READ_ALL, 'f')
  63. expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
  64. expect(result.totalLines).toBe(2)
  65. })
  66. describe('caps are per-request (the plugin config reaches the window)', () => {
  67. it('truncates lines at a custom maxLineLength and names it in the suffix', async () => {
  68. const result = await buildWindow(whole('abcdefghij'), { offset: 1, limit: 10, maxLineLength: 5, maxBytes: READ_MAX_BYTES }, 'f')
  69. expect(result.lines[0]?.text).toBe('abcde... (line truncated to 5 chars)')
  70. })
  71. it('caps output at a custom maxBytes', async () => {
  72. const result = await buildWindow(whole('aaaa\nbbbb\ncccc'), { offset: 1, limit: 10, maxLineLength: 2000, maxBytes: 9 }, 'f')
  73. expect(result.lines.map(l => l.text)).toEqual(['aaaa', 'bbbb'])
  74. expect(result.totalLines).toBe(3)
  75. expect(result.truncatedByBytes).toBe(true)
  76. })
  77. })
  78. describe('chunked input (streamed read shape)', () => {
  79. it('windows identically when text arrives in small chunks', async () => {
  80. const result = await buildWindow(chunked('one\ntwo\nthree', 2), { offset: 2, limit: 1, ...DEFAULT_CAPS }, 'f')
  81. expect(result.lines).toEqual([{ number: 2, text: 'two' }])
  82. expect(result.totalLines).toBe(3)
  83. })
  84. it('caps a newline-free giant line split across chunks without unbounded buffering', async () => {
  85. const result = await buildWindow(chunked('z'.repeat(5000), 256), READ_ALL, 'f')
  86. expect(result.lines[0]?.text).toContain(`... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`)
  87. })
  88. it('caps output bytes mid-stream', async () => {
  89. const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')
  90. const result = await buildWindow(chunked(big, 512), READ_ALL, 'f')
  91. expect(result.totalLines).toBe(2000)
  92. expect(result.truncatedByBytes).toBe(true)
  93. })
  94. it('flushes a final newline-terminated line across a chunk boundary', async () => {
  95. const result = await buildWindow(chunked('one\ntwo\n', 3), READ_ALL, 'f')
  96. expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
  97. })
  98. })
  99. })
  100. describe('langFromPath', () => {
  101. it('maps a known extension to its language hint, case-insensitively', () => {
  102. expect(langFromPath('src/a.ts')).toBe('ts')
  103. expect(langFromPath('src/a.TSX')).toBe('tsx')
  104. expect(langFromPath('/abs/module.mjs')).toBe('js')
  105. expect(langFromPath('conf.yml')).toBe('yaml')
  106. expect(langFromPath('README.md')).toBe('md')
  107. })
  108. it('reads the extension after the last path segment and last dot', () => {
  109. expect(langFromPath('a.py.bak')).toBeUndefined()
  110. expect(langFromPath('archive.tar.gz')).toBeUndefined()
  111. expect(langFromPath('/dir.py/plain')).toBeUndefined()
  112. expect(langFromPath('C:\\src\\main.rs')).toBe('rs')
  113. })
  114. it('returns undefined for a dotfile, an extensionless name, and an unknown extension', () => {
  115. expect(langFromPath('.gitignore')).toBeUndefined()
  116. expect(langFromPath('/etc/hosts')).toBeUndefined()
  117. expect(langFromPath('data.unknownext')).toBeUndefined()
  118. expect(langFromPath('trailingdot.')).toBeUndefined()
  119. })
  120. it('returns undefined for a filename whose extension is an Object.prototype key', () => {
  121. // Own-property lookup only: these must not resolve to the inherited member
  122. // (a function/object), which would fail the tool-output JSON validation.
  123. expect(langFromPath('foo.constructor')).toBeUndefined()
  124. expect(langFromPath('foo.__proto__')).toBeUndefined()
  125. expect(langFromPath('foo.toString')).toBeUndefined()
  126. expect(langFromPath('foo.hasOwnProperty')).toBeUndefined()
  127. })
  128. })
  129. describe('readMetaFromMeta', () => {
  130. const good = { path: '/abs/a.ts', offset: 1, lines: [{ number: 1, text: 'x' }], totalLines: 1, lang: 'ts' }
  131. it('narrows a well-formed read meta, with and without a lang hint', () => {
  132. expect(readMetaFromMeta(good)).toEqual(good)
  133. const noLang = { path: '/abs/a', offset: 1, lines: [], totalLines: 0 }
  134. expect(readMetaFromMeta(noLang)).toEqual(noLang)
  135. })
  136. it('narrows an empty window at a positive offset (byte cap below the first selected line)', () => {
  137. const empty = { path: '/abs/a', offset: 5, lines: [], totalLines: 9 }
  138. expect(readMetaFromMeta(empty)).toEqual(empty)
  139. })
  140. it('returns undefined for absent, non-object, or array meta', () => {
  141. expect(readMetaFromMeta(undefined)).toBeUndefined()
  142. expect(readMetaFromMeta(null)).toBeUndefined()
  143. expect(readMetaFromMeta('nope')).toBeUndefined()
  144. expect(readMetaFromMeta([good])).toBeUndefined()
  145. })
  146. it('returns undefined when a field is missing or the wrong type (defensive narrowing)', () => {
  147. expect(readMetaFromMeta({ ...good, path: 5 })).toBeUndefined()
  148. expect(readMetaFromMeta({ ...good, offset: '1' })).toBeUndefined()
  149. expect(readMetaFromMeta({ ...good, totalLines: '1' })).toBeUndefined()
  150. expect(readMetaFromMeta({ ...good, lines: 'nope' })).toBeUndefined()
  151. expect(readMetaFromMeta({ ...good, lines: [{ number: '1', text: 'x' }] })).toBeUndefined()
  152. expect(readMetaFromMeta({ ...good, lines: [{ number: 1 }] })).toBeUndefined()
  153. expect(readMetaFromMeta({ ...good, lines: [null] })).toBeUndefined()
  154. expect(readMetaFromMeta({ ...good, lang: 5 })).toBeUndefined()
  155. })
  156. it('rejects an offset that is not a 1-based integer', () => {
  157. expect(readMetaFromMeta({ ...good, offset: 0 })).toBeUndefined()
  158. expect(readMetaFromMeta({ ...good, offset: 1.5 })).toBeUndefined()
  159. expect(readMetaFromMeta({ ...good, offset: NaN })).toBeUndefined()
  160. expect(readMetaFromMeta({ ...good, offset: Infinity })).toBeUndefined()
  161. })
  162. it('rejects a first line number below offset', () => {
  163. expect(readMetaFromMeta({ ...good, offset: 2, lines: [{ number: 1, text: 'x' }], totalLines: 2 })).toBeUndefined()
  164. })
  165. it('rejects a line number that is not a 1-based integer', () => {
  166. expect(readMetaFromMeta({ ...good, lines: [{ number: 0, text: 'x' }], totalLines: 1 })).toBeUndefined()
  167. expect(readMetaFromMeta({ ...good, lines: [{ number: 1.5, text: 'x' }], totalLines: 2 })).toBeUndefined()
  168. expect(readMetaFromMeta({ ...good, lines: [{ number: NaN, text: 'x' }], totalLines: 1 })).toBeUndefined()
  169. expect(readMetaFromMeta({ ...good, lines: [{ number: Infinity, text: 'x' }], totalLines: 1 })).toBeUndefined()
  170. })
  171. it('rejects a totalLines that is not a non-negative integer', () => {
  172. expect(readMetaFromMeta({ ...good, totalLines: -1 })).toBeUndefined()
  173. expect(readMetaFromMeta({ ...good, totalLines: 1.5 })).toBeUndefined()
  174. expect(readMetaFromMeta({ ...good, totalLines: NaN })).toBeUndefined()
  175. })
  176. it('rejects lines that do not strictly increase or exceed totalLines', () => {
  177. const twoLines = { path: '/abs/a', offset: 1, lang: 'ts' }
  178. // Duplicate line numbers.
  179. expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 1, text: 'a' }, { number: 1, text: 'b' }], totalLines: 2 })).toBeUndefined()
  180. // Out-of-order line numbers.
  181. expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 2, text: 'b' }, { number: 1, text: 'a' }], totalLines: 2 })).toBeUndefined()
  182. // A line number past totalLines.
  183. expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 3, text: 'c' }], totalLines: 2 })).toBeUndefined()
  184. })
  185. })