readMetaFromMeta narrows the opaque persisted meta boundary, so beyond shape it now rejects replayed JSON that is well-typed but semantically invalid: line numbers must be 1-based integers, totalLines a non-negative integer, and line numbers must strictly increase without exceeding totalLines. Any violation declines to the generic fallback. Sync the public ToolResultView contract across the core/tools and tool-fs READMEs and docs/core-data-structures/tools for the fourth result-view member and the ReadFileLine vocabulary, and expand the Agent Note Testing section with the new rejection paths and the snapshot evidence this PR carries.
193 lines
8.9 KiB
TypeScript
193 lines
8.9 KiB
TypeScript
/**
|
|
* Cordis-free tests for the line-windowing module: offset/limit windows, byte
|
|
* caps, per-line truncation, CRLF stripping, offset-past-EOF rejection, and the
|
|
* capped line buffer for newline-free giant lines — all over an async-iterable
|
|
* of decoded text chunks (so one code path serves whole-file and streamed reads).
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import { buildWindow, langFromPath, readMetaFromMeta, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts'
|
|
import type { ReadWindow } from '../src/read-render.ts'
|
|
|
|
const DEFAULT_CAPS = { maxLineLength: READ_MAX_LINE_LENGTH, maxBytes: READ_MAX_BYTES }
|
|
const READ_ALL: ReadWindow = { offset: 1, limit: 2000, ...DEFAULT_CAPS }
|
|
|
|
/** Yield `text` as one chunk (whole-file read shape). */
|
|
async function* whole(text: string): AsyncIterable<string> {
|
|
yield text
|
|
}
|
|
|
|
/** Yield `text` split into fixed-size chunks (streamed read shape). */
|
|
async function* chunked(text: string, size: number): AsyncIterable<string> {
|
|
for (let i = 0; i < text.length; i += size) yield text.slice(i, i + size)
|
|
}
|
|
|
|
describe('buildWindow', () => {
|
|
it('numbers lines and reports total for a whole-file read', async () => {
|
|
const result = await buildWindow(whole('one\ntwo\nthree'), READ_ALL, 'f')
|
|
expect(result.lines).toEqual([
|
|
{ number: 1, text: 'one' },
|
|
{ number: 2, text: 'two' },
|
|
{ number: 3, text: 'three' },
|
|
])
|
|
expect(result.totalLines).toBe(3)
|
|
expect(result.truncatedByBytes).toBe(false)
|
|
})
|
|
|
|
it('applies offset/limit', async () => {
|
|
const result = await buildWindow(whole('one\ntwo\nthree\nfour'), { offset: 2, limit: 2, ...DEFAULT_CAPS }, 'f')
|
|
expect(result.lines.map(l => l.number)).toEqual([2, 3])
|
|
expect(result.totalLines).toBe(4)
|
|
})
|
|
|
|
it('strips CRLF', async () => {
|
|
const result = await buildWindow(whole('one\r\ntwo\r\n'), READ_ALL, 'f')
|
|
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
|
})
|
|
|
|
it('truncates an over-long line', async () => {
|
|
const result = await buildWindow(whole('x'.repeat(3000)), READ_ALL, 'f')
|
|
expect(result.lines[0]?.text).toContain(`... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`)
|
|
})
|
|
|
|
it('caps output bytes and reports truncatedByBytes', async () => {
|
|
const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')
|
|
const result = await buildWindow(whole(big), READ_ALL, 'f')
|
|
expect(result.truncatedByBytes).toBe(true)
|
|
})
|
|
|
|
it('reads an empty file at offset 1 as zero lines', async () => {
|
|
const result = await buildWindow(whole(''), READ_ALL, 'f')
|
|
expect(result.lines).toEqual([])
|
|
expect(result.totalLines).toBe(0)
|
|
})
|
|
|
|
it('rejects an offset past EOF', async () => {
|
|
await expect(buildWindow(whole('one\ntwo'), { offset: 9, limit: 1, ...DEFAULT_CAPS }, 'f')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
|
})
|
|
|
|
it('flushes a final line with no trailing newline', async () => {
|
|
const result = await buildWindow(whole('one\ntwo'), READ_ALL, 'f')
|
|
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
|
})
|
|
|
|
it('handles a trailing newline (no dangling empty line)', async () => {
|
|
const result = await buildWindow(whole('one\ntwo\n'), READ_ALL, 'f')
|
|
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
|
expect(result.totalLines).toBe(2)
|
|
})
|
|
|
|
describe('caps are per-request (the plugin config reaches the window)', () => {
|
|
it('truncates lines at a custom maxLineLength and names it in the suffix', async () => {
|
|
const result = await buildWindow(whole('abcdefghij'), { offset: 1, limit: 10, maxLineLength: 5, maxBytes: READ_MAX_BYTES }, 'f')
|
|
expect(result.lines[0]?.text).toBe('abcde... (line truncated to 5 chars)')
|
|
})
|
|
|
|
it('caps output at a custom maxBytes', async () => {
|
|
const result = await buildWindow(whole('aaaa\nbbbb\ncccc'), { offset: 1, limit: 10, maxLineLength: 2000, maxBytes: 9 }, 'f')
|
|
expect(result.lines.map(l => l.text)).toEqual(['aaaa', 'bbbb'])
|
|
expect(result.totalLines).toBe(3)
|
|
expect(result.truncatedByBytes).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('chunked input (streamed read shape)', () => {
|
|
it('windows identically when text arrives in small chunks', async () => {
|
|
const result = await buildWindow(chunked('one\ntwo\nthree', 2), { offset: 2, limit: 1, ...DEFAULT_CAPS }, 'f')
|
|
expect(result.lines).toEqual([{ number: 2, text: 'two' }])
|
|
expect(result.totalLines).toBe(3)
|
|
})
|
|
|
|
it('caps a newline-free giant line split across chunks without unbounded buffering', async () => {
|
|
const result = await buildWindow(chunked('z'.repeat(5000), 256), READ_ALL, 'f')
|
|
expect(result.lines[0]?.text).toContain(`... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`)
|
|
})
|
|
|
|
it('caps output bytes mid-stream', async () => {
|
|
const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')
|
|
const result = await buildWindow(chunked(big, 512), READ_ALL, 'f')
|
|
expect(result.totalLines).toBe(2000)
|
|
expect(result.truncatedByBytes).toBe(true)
|
|
})
|
|
|
|
it('flushes a final newline-terminated line across a chunk boundary', async () => {
|
|
const result = await buildWindow(chunked('one\ntwo\n', 3), READ_ALL, 'f')
|
|
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('langFromPath', () => {
|
|
it('maps a known extension to its language hint, case-insensitively', () => {
|
|
expect(langFromPath('src/a.ts')).toBe('ts')
|
|
expect(langFromPath('src/a.TSX')).toBe('tsx')
|
|
expect(langFromPath('/abs/module.mjs')).toBe('js')
|
|
expect(langFromPath('conf.yml')).toBe('yaml')
|
|
expect(langFromPath('README.md')).toBe('md')
|
|
})
|
|
|
|
it('reads the extension after the last path segment and last dot', () => {
|
|
expect(langFromPath('a.py.bak')).toBeUndefined()
|
|
expect(langFromPath('archive.tar.gz')).toBeUndefined()
|
|
expect(langFromPath('/dir.py/plain')).toBeUndefined()
|
|
expect(langFromPath('C:\\src\\main.rs')).toBe('rs')
|
|
})
|
|
|
|
it('returns undefined for a dotfile, an extensionless name, and an unknown extension', () => {
|
|
expect(langFromPath('.gitignore')).toBeUndefined()
|
|
expect(langFromPath('/etc/hosts')).toBeUndefined()
|
|
expect(langFromPath('data.unknownext')).toBeUndefined()
|
|
expect(langFromPath('trailingdot.')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('readMetaFromMeta', () => {
|
|
const good = { path: '/abs/a.ts', lines: [{ number: 1, text: 'x' }], totalLines: 1, lang: 'ts' }
|
|
|
|
it('narrows a well-formed read meta, with and without a lang hint', () => {
|
|
expect(readMetaFromMeta(good)).toEqual(good)
|
|
const noLang = { path: '/abs/a', lines: [], totalLines: 0 }
|
|
expect(readMetaFromMeta(noLang)).toEqual(noLang)
|
|
})
|
|
|
|
it('returns undefined for absent, non-object, or array meta', () => {
|
|
expect(readMetaFromMeta(undefined)).toBeUndefined()
|
|
expect(readMetaFromMeta(null)).toBeUndefined()
|
|
expect(readMetaFromMeta('nope')).toBeUndefined()
|
|
expect(readMetaFromMeta([good])).toBeUndefined()
|
|
})
|
|
|
|
it('returns undefined when a field is missing or the wrong type (defensive narrowing)', () => {
|
|
expect(readMetaFromMeta({ ...good, path: 5 })).toBeUndefined()
|
|
expect(readMetaFromMeta({ ...good, totalLines: '1' })).toBeUndefined()
|
|
expect(readMetaFromMeta({ ...good, lines: 'nope' })).toBeUndefined()
|
|
expect(readMetaFromMeta({ ...good, lines: [{ number: '1', text: 'x' }] })).toBeUndefined()
|
|
expect(readMetaFromMeta({ ...good, lines: [{ number: 1 }] })).toBeUndefined()
|
|
expect(readMetaFromMeta({ ...good, lines: [null] })).toBeUndefined()
|
|
expect(readMetaFromMeta({ ...good, lang: 5 })).toBeUndefined()
|
|
})
|
|
|
|
it('rejects a line number that is not a 1-based integer', () => {
|
|
expect(readMetaFromMeta({ ...good, lines: [{ number: 0, text: 'x' }], totalLines: 1 })).toBeUndefined()
|
|
expect(readMetaFromMeta({ ...good, lines: [{ number: 1.5, text: 'x' }], totalLines: 2 })).toBeUndefined()
|
|
expect(readMetaFromMeta({ ...good, lines: [{ number: NaN, text: 'x' }], totalLines: 1 })).toBeUndefined()
|
|
expect(readMetaFromMeta({ ...good, lines: [{ number: Infinity, text: 'x' }], totalLines: 1 })).toBeUndefined()
|
|
})
|
|
|
|
it('rejects a totalLines that is not a non-negative integer', () => {
|
|
expect(readMetaFromMeta({ ...good, totalLines: -1 })).toBeUndefined()
|
|
expect(readMetaFromMeta({ ...good, totalLines: 1.5 })).toBeUndefined()
|
|
expect(readMetaFromMeta({ ...good, totalLines: NaN })).toBeUndefined()
|
|
})
|
|
|
|
it('rejects lines that do not strictly increase or exceed totalLines', () => {
|
|
const twoLines = { path: '/abs/a', lang: 'ts' }
|
|
// Duplicate line numbers.
|
|
expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 1, text: 'a' }, { number: 1, text: 'b' }], totalLines: 2 })).toBeUndefined()
|
|
// Out-of-order line numbers.
|
|
expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 2, text: 'b' }, { number: 1, text: 'a' }], totalLines: 2 })).toBeUndefined()
|
|
// A line number past totalLines.
|
|
expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 3, text: 'c' }], totalLines: 2 })).toBeUndefined()
|
|
})
|
|
})
|