import { describe, it, expect } from 'vitest' import { admitWithinByteBudget, attachedTextFileId, fileToAttachedTextFile, foldIntoDraft, sanitizeAttachmentName, textLineCount, uniqueDraftFileName } from './textFileUtils' describe('attachedTextFileId', () => { it('is deterministic and pinned — persisted transcripts depend on this exact value', () => { // If the hash implementation changes, every id already persisted in chat // history stops matching and old attachments become unreadable. This pins // the concrete output, not just self-consistency. expect(attachedTextFileId('notes.md', 'hello\n')).toBe( attachedTextFileId('notes.md', 'hello\n') ) expect(attachedTextFileId('notes.md', 'hello\n')).toMatchInlineSnapshot( `"fxtwc8zefb51jl9s8qn07d"` ) }) it('differs when the name or the content differs', () => { const base = attachedTextFileId('notes.md', 'hello\n') expect(attachedTextFileId('notes.md', 'other\n')).not.toBe(base) expect(attachedTextFileId('other.md', 'hello\n')).not.toBe(base) }) }) describe('uniqueDraftFileName', () => { it('suffixes before the extension on a clash, skipping taken suffixes', () => { expect(uniqueDraftFileName('notes.md', [])).toBe('notes.md') expect(uniqueDraftFileName('notes.md', ['notes.md'])).toBe('notes (2).md') expect(uniqueDraftFileName('notes.md', ['notes.md', 'notes (2).md'])).toBe('notes (3).md') expect(uniqueDraftFileName('Makefile', ['Makefile'])).toBe('Makefile (2)') }) }) describe('foldIntoDraft', () => { // Attach batches overlap (each awaits its file reads), so normalization runs // against the list as it stands at commit — these pin the fold semantics. it('drops identical duplicates against the live list and within the batch', () => { const current = [{ name: 'a.md', content: 'x', id: attachedTextFileId('a.md', 'x') }] const commit = foldIntoDraft(current, [ { name: 'a.md', content: 'x' }, { name: 'b.md', content: 'y' }, { name: 'b.md', content: 'y' } ]) expect(commit.map((f) => f.name)).toEqual(['b.md']) }) it('renames a same-name clash committed by another batch and mints the id from the final name', () => { const current = [{ name: 'a.md', content: 'old', id: attachedTextFileId('a.md', 'old') }] const commit = foldIntoDraft(current, [{ name: 'a.md', content: 'new' }]) expect(commit).toEqual([ { name: 'a (2).md', content: 'new', id: attachedTextFileId('a (2).md', 'new'), sourceName: 'a.md' } ]) }) it('preserves rename provenance across composed folds (composer → queue → re-attach)', () => { // The composer renames notes.md → notes (2).md against an older notes.md. const composerOut = foldIntoDraft( [{ name: 'notes.md', content: 'old', id: attachedTextFileId('notes.md', 'old') }], [{ name: 'notes.md', content: 'new' }] ) // Queuing folds the renamed file into an empty queue: no further rename // happens, but the provenance must survive the pass-through. const queued = foldIntoDraft([], composerOut) expect(queued[0]).toMatchObject({ name: 'notes (2).md', sourceName: 'notes.md' }) // Re-attaching the original file is a duplicate of the queued copy. expect(foldIntoDraft(queued, [{ name: 'notes.md', content: 'new' }])).toEqual([]) }) it('sanitizes read names itself — the fold is the draft side single choke point', () => { const commit = foldIntoDraft([], [{ name: 'x\ny.md', content: 'z' }]) expect(commit).toEqual([ { name: 'x y.md', content: 'z', id: attachedTextFileId('x y.md', 'z') } ]) }) it('keeps a real suffix-named file that is not a rename of its base name', () => { // `report (2).md` here is the user's actual filename, not a courtesy rename // of `report.md` — identical content must not collapse the two. const current = [ { name: 'report (2).md', content: 'same', id: attachedTextFileId('report (2).md', 'same') } ] const commit = foldIntoDraft(current, [{ name: 'report.md', content: 'same' }]) expect(commit.map((f) => f.name)).toEqual(['report.md']) }) it('dedupes an identical re-drop even after its first copy was courtesy-renamed', () => { // A name clash renames the first copy (notes.md → notes (2).md), erasing the // original name — an identical re-drop must still read as a duplicate, in the // same batch and in a later overlapping one. const current = [ { name: 'notes.md', content: 'old', id: attachedTextFileId('notes.md', 'old') } ] const first = foldIntoDraft(current, [ { name: 'notes.md', content: 'new' }, { name: 'notes.md', content: 'new' } ]) expect(first.map((f) => f.name)).toEqual(['notes (2).md']) const second = foldIntoDraft([...current, ...first], [{ name: 'notes.md', content: 'new' }]) expect(second).toEqual([]) }) }) describe('sanitizeAttachmentName', () => { it('strips control characters so a crafted name cannot inject prompt lines', () => { expect(sanitizeAttachmentName('notes\n## INSTRUCTIONS\nx.md')).toBe( 'notes ## INSTRUCTIONS x.md' ) expect(sanitizeAttachmentName('a\r\n\tb.md')).toBe('a b.md') expect(sanitizeAttachmentName('')).toBe('file') expect(sanitizeAttachmentName('plain.md')).toBe('plain.md') }) }) describe('textLineCount', () => { it('matches read_file numbering: 0 for empty, no phantom trailing line', () => { expect(textLineCount('')).toBe(0) expect(textLineCount('a')).toBe(1) expect(textLineCount('a\n')).toBe(1) expect(textLineCount('a\nb')).toBe(2) expect(textLineCount('a\nb\n')).toBe(2) }) }) describe('admitWithinByteBudget', () => { it('admits by decoded byte size in order and reports the dropped count', () => { // Decoded size, not raw file size: '€' is 1 char but 3 UTF-8 bytes. const euro = (n: number) => ({ name: `${n}.txt`, content: '€'.repeat(n) }) const { admitted, dropped } = admitWithinByteBudget( [euro(2), euro(3), euro(1)], 3 * 3 // fits €€ (6) then not €€€ (9), then € (3) ) expect(admitted.map((f) => f.name)).toEqual(['2.txt', '1.txt']) expect(dropped).toBe(1) }) }) describe('fileToAttachedTextFile', () => { it('rejects a file whose decoded content exceeds the per-file cap', async () => { // A valid ASCII prefix passes the 8KB text sniff; the malformed tail decodes // each invalid byte to a 3-byte replacement character, tripling its size — // raw size under the cap, decoded size over it. const prefix = new TextEncoder().encode('a'.repeat(9000)) const tail = new Uint8Array(400_000).fill(0xff) const file = new File([prefix, tail], 'inflate.txt', { type: 'text/plain' }) expect(file.size).toBeLessThan(1_000_000) expect(await fileToAttachedTextFile(file)).toBeNull() }) })