import { describe, expect, it } from 'vitest'; import iconv from 'iconv-lite'; import { detectTextEncoding, decodeToUtf8, encodeFromUtf8, decodeFileText, encodeFileText } from './text-decode.js'; const JP = 'こんにちは世界、これはテストです。\n日本語のテキスト。\n'; describe('detectTextEncoding', () => { it('keeps plain ASCII as utf-8', () => { const v = detectTextEncoding(Buffer.from('hello world\nsecond line\n', 'ascii')); expect(v).toEqual({ binary: false, encoding: 'utf-8' }); }); it('keeps valid UTF-8 Japanese as utf-8', () => { const v = detectTextEncoding(Buffer.from(JP, 'utf-8')); expect(v).toEqual({ binary: false, encoding: 'utf-8' }); }); it('recovers Shift_JIS (CP932) Japanese text as text, not binary', () => { const v = detectTextEncoding(iconv.encode(JP, 'Shift_JIS')); expect(v.binary).toBe(false); if (!v.binary) expect(v.encoding).toBe('shift_jis'); }); it('recovers EUC-JP Japanese text as text, not binary', () => { const v = detectTextEncoding(iconv.encode(JP, 'EUC-JP')); expect(v.binary).toBe(false); if (!v.binary) expect(v.encoding).toBe('euc-jp'); }); it('distinguishes kana-heavy EUC-JP from Shift_JIS (tie-break, codex regression)', () => { // Both decode without mojibake, but EUC-JP kana read as Shift_JIS lands in // halfwidth katakana; the body-text tie-break must pick the right one. const kana = 'あいうえお、こんにちは。さようなら\n'; const euc = detectTextEncoding(iconv.encode(kana, 'EUC-JP')); expect(euc.binary).toBe(false); if (!euc.binary) expect(euc.encoding).toBe('euc-jp'); const sjis = detectTextEncoding(iconv.encode(kana, 'Shift_JIS')); expect(sjis.binary).toBe(false); if (!sjis.binary) expect(sjis.encoding).toBe('shift_jis'); }); it('recovers windows-1252 / Latin-1 accented text as text', () => { const v = detectTextEncoding(iconv.encode('café résumé naïve Zürich\n', 'windows-1252')); expect(v.binary).toBe(false); }); it('still reports a PNG as binary (magic byte)', () => { const png = Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from('IHDR'), Buffer.from([0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]), ]); const v = detectTextEncoding(png); expect(v.binary).toBe(true); }); it('still reports NUL-heavy binary as binary', () => { const v = detectTextEncoding(Buffer.from([0x00, 0x01, 0x02, 0x00, 0x03, 0x00, 0xff, 0x00])); expect(v.binary).toBe(true); }); it('rejects high-entropy binary that windows-1252 would decode without replacement chars', () => { // Regression (codex P2): bytes 0xA0–0xFF have no NUL/magic/C0 controls and // decode cleanly under cp1252, but are a symbol soup — not real text. const blob = Buffer.from(Array.from({ length: 100 }, (_, i) => 0xa0 + ((i * 37) % 96))); const v = detectTextEncoding(blob); expect(v.binary).toBe(true); }); it('reports high-control random bytes (not valid UTF-8) as binary', () => { // 0x81/0x8f... with lots of C0 controls: not utf-8, high control ratio → binary. const junk = Buffer.from([ 0x81, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x8f, 0x90, 0x9d, ]); const v = detectTextEncoding(junk); expect(v.binary).toBe(true); }); }); describe('decodeToUtf8 / encodeFromUtf8 round-trip', () => { it('decodes Shift_JIS bytes to the original UTF-8 string', () => { const decoded = decodeToUtf8(iconv.encode(JP, 'Shift_JIS'), 'shift_jis'); expect(decoded).toBe(JP); }); it('re-encodes a UTF-8 edit back into Shift_JIS bytes losslessly', () => { const edited = JP.replace('テスト', 'サンプル'); const bytes = encodeFromUtf8(edited, 'shift_jis'); expect(iconv.decode(bytes, 'Shift_JIS')).toBe(edited); // and the bytes are genuinely Shift_JIS (not utf-8): decoding as utf-8 differs expect(bytes.toString('utf-8')).not.toBe(edited); }); it('passes utf-8 through unchanged', () => { expect(decodeToUtf8(Buffer.from(JP, 'utf-8'), 'utf-8')).toBe(JP); expect(encodeFromUtf8(JP, 'utf-8').equals(Buffer.from(JP, 'utf-8'))).toBe(true); }); }); describe('decodeFileText (whole-file detection)', () => { it('detects Shift_JIS even when the first 8KB is pure ASCII', () => { // Long ASCII header (> SNIFF_HEAD_BYTES) then Shift_JIS Japanese tail. const header = Buffer.from('# '.padEnd(9000, 'header-comment ') + '\n', 'ascii'); const tail = iconv.encode('本文は日本語です。\n', 'Shift_JIS'); const full = Buffer.concat([header, tail]); const r = decodeFileText(full); expect(r.binary).toBe(false); if (!r.binary) { expect(r.encoding).toBe('shift_jis'); expect(r.text).toContain('本文は日本語です'); expect(r.text).not.toContain('�'); } }); it('reads a pure UTF-8 file via the fast path', () => { const r = decodeFileText(Buffer.from(JP, 'utf-8')); expect(r).toEqual({ binary: false, encoding: 'utf-8', text: JP }); }); it('does not silently swallow a truncated trailing UTF-8 byte as empty text', () => { // Regression (codex): streaming decode returned {binary:false, text:''} for a // lone 0xE3; a complete valid body followed by a truncated byte must keep the body. const lone = decodeFileText(Buffer.from([0xe3])); expect(lone.binary === false && lone.text === '').toBe(false); const truncated = decodeFileText(Buffer.concat([Buffer.from('あいう', 'utf-8'), Buffer.from([0xe3])])); expect(truncated.binary).toBe(false); if (!truncated.binary) expect(truncated.text).toContain('あいう'); }); it('rejects binary hidden behind a > 8KB ASCII prefix (NUL in the tail)', () => { // Regression (codex): head-only detection saw pure ASCII and NUL is valid // UTF-8, so a NUL-bearing tail slipped through as text. Whole-file NUL check. const asciiHead = Buffer.from('a'.repeat(9000), 'ascii'); const nulTail = Buffer.from([0x00, 0x01, 0x02, 0x00, 0xff, 0x00]); expect(decodeFileText(Buffer.concat([asciiHead, nulTail])).binary).toBe(true); }); it('preserves a UTF-8 BOM through decode/encode (labels it utf-8-bom)', () => { const withBom = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('見出し\n本文', 'utf-8')]); const r = decodeFileText(withBom); expect(r.binary).toBe(false); if (!r.binary) { expect(r.encoding).toBe('utf-8-bom'); expect(r.text.charCodeAt(0)).not.toBe(0xfeff); // BOM stripped from the text view const reencoded = encodeFileText(r.text, r.encoding)!; expect([reencoded[0], reencoded[1], reencoded[2]]).toEqual([0xef, 0xbb, 0xbf]); } }); it('rejects a magic-byte binary whose bytes are valid UTF-8 (PDF/ZIP header)', () => { // '%PDF-1.4\nneedle\n' is valid UTF-8, but the magic byte must win over the // UTF-8 fast path so Grep does not leak it. expect(decodeFileText(Buffer.from('%PDF-1.4\nneedle\n', 'ascii')).binary).toBe(true); expect(decodeFileText(Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x6e, 0x65, 0x65, 0x64])).binary).toBe(true); }); }); describe('encodeFileText (round-trip guard)', () => { it('returns bytes when the text is representable in the target encoding', () => { const bytes = encodeFileText('日本語テキスト\n', 'shift_jis'); expect(bytes).not.toBeNull(); expect(iconv.decode(bytes!, 'Shift_JIS')).toBe('日本語テキスト\n'); }); it('returns null when the text has chars the encoding cannot represent (emoji into Shift_JIS)', () => { expect(encodeFileText('絵文字😀を含む\n', 'shift_jis')).toBeNull(); }); it('re-adds the BOM when encoding UTF-16 so the file stays readable next time', () => { const le = encodeFromUtf8('日本語X\n', 'utf-16le'); expect([le[0], le[1]]).toEqual([0xff, 0xfe]); const be = encodeFromUtf8('日本語X\n', 'utf-16be'); expect([be[0], be[1]]).toEqual([0xfe, 0xff]); // Round-trips through decodeFileText as UTF-16, not rejected as binary. const back = decodeFileText(le); expect(back.binary).toBe(false); if (!back.binary) expect(back.text.replace(/^/, '')).toBe('日本語X\n'); }); });