import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { mkdtemp, mkdir, writeFile, rm, symlink } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' import { scanFolders, semanticEquals, SEMANTIC_MAX_BYTES, type FolderEntry } from './folderScan' /** 以临时目录为根构造两个待对比文件夹 */ let leftDir: string let rightDir: string beforeAll(async () => { const root = await mkdtemp(join(tmpdir(), 'difflens-scan-')) leftDir = join(root, 'left') rightDir = join(root, 'right') await mkdir(leftDir, { recursive: true }) await mkdir(rightDir, { recursive: true }) }) afterAll(async () => { await rm(join(leftDir, '..'), { recursive: true, force: true }) }) async function put(dir: 'left' | 'right', rel: string, content: string | Buffer): Promise { const path = join(dir === 'left' ? leftDir : rightDir, rel) const idx = rel.lastIndexOf('/') if (idx >= 0) await mkdir(join(path, '..'), { recursive: true }) await writeFile(path, content) } const byRel = (entries: FolderEntry[], rel: string): FolderEntry | undefined => entries.find((e) => e.rel === rel) describe('scanFolders - 状态判定', () => { it('两侧内容一致判为 same,内容不同(同大小)判为 different,大小不同判为 different', async () => { await put('left', 'same.txt', 'hello') await put('right', 'same.txt', 'hello') await put('left', 'content-diff.txt', 'abc') await put('right', 'content-diff.txt', 'abd') await put('left', 'size-diff.txt', 'short') await put('right', 'size-diff.txt', 'much longer text') const r = await scanFolders(leftDir, rightDir) expect(r.truncated).toBe(false) expect(byRel(r.entries, 'same.txt')).toMatchObject({ status: 'same', leftSize: 5, rightSize: 5 }) expect(byRel(r.entries, 'content-diff.txt')?.status).toBe('different') expect(byRel(r.entries, 'size-diff.txt')).toMatchObject({ status: 'different' }) }) it('单侧缺失判为 only,大小字段另一侧为 null', async () => { await put('left', 'only-left.txt', 'L') await put('right', 'only-right.txt', 'R') const r = await scanFolders(leftDir, rightDir) expect(byRel(r.entries, 'only-left.txt')).toMatchObject({ status: 'left-only', leftSize: 1, rightSize: null }) expect(byRel(r.entries, 'only-right.txt')).toMatchObject({ status: 'right-only', leftSize: null, rightSize: 1 }) }) it('递归子目录条目按相对路径对齐(统一 / 分隔)', async () => { await put('left', 'src/core/a.ts', 'export const a = 1\n') await put('right', 'src/core/a.ts', 'export const a = 2\n') await put('right', 'src/util/b.ts', 'export const b = 1\n') const r = await scanFolders(leftDir, rightDir) expect(byRel(r.entries, 'src/core/a.ts')?.status).toBe('different') expect(byRel(r.entries, 'src/util/b.ts')?.status).toBe('right-only') }) it('二进制内容同样按字节判定(含 NUL 字节文件)', async () => { await put('left', 'bin.dat', Buffer.from([0x00, 0x01, 0x02, 0x03])) await put('right', 'bin.dat', Buffer.from([0x00, 0x01, 0x02, 0x04])) const r = await scanFolders(leftDir, rightDir) expect(byRel(r.entries, 'bin.dat')?.status).toBe('different') }) it('symlink 被跳过(防环且不参与判定)', async () => { // 建一个指向已存在子目录的 symlink,不应出现在结果中也不应导致递归 await symlink(join(leftDir, 'src'), join(leftDir, 'src-link'), 'dir').catch(() => {}) const r = await scanFolders(leftDir, rightDir) expect(r.entries.some((e) => e.rel.startsWith('src-link/'))).toBe(false) }) it('条目按相对路径字典序排序', async () => { const r = await scanFolders(leftDir, rightDir) const rels = r.entries.map((e) => e.rel) expect([...rels].sort()).toEqual(rels) }) it('total 为两侧枚举文件数的较大值', async () => { const r = await scanFolders(leftDir, rightDir) const leftCount = r.entries.filter((e) => e.leftSize !== null).length const rightCount = r.entries.filter((e) => e.rightSize !== null).length expect(r.total).toBeGreaterThanOrEqual(Math.max(leftCount, rightCount)) }) }) describe('scanFolders - 边界与选项', () => { it('两个空目录返回空结果', async () => { const emptyL = await mkdtemp(join(tmpdir(), 'difflens-empty-l-')) const emptyR = await mkdtemp(join(tmpdir(), 'difflens-empty-r-')) try { const r = await scanFolders(emptyL, emptyR) expect(r.entries).toEqual([]) expect(r.total).toBe(0) expect(r.truncated).toBe(false) } finally { await rm(emptyL, { recursive: true, force: true }) await rm(emptyR, { recursive: true, force: true }) } }) it('目录不存在时不再抛异常:该侧按无文件处理(另一侧条目为 only)', async () => { // 单目录 IO 失败降级(被锁/权限)而非整体失败:大目录下单点锁不丢弃全部结果 const r = await scanFolders(join(leftDir, 'not-exist'), rightDir) expect(r.entries.length).toBeGreaterThan(0) expect(r.entries.every((e) => e.status === 'right-only')).toBe(true) }) it('maxFiles 截断:条目不完整且 truncated 为 true', async () => { const l = await mkdtemp(join(tmpdir(), 'difflens-trunc-l-')) const r = await mkdtemp(join(tmpdir(), 'difflens-trunc-r-')) try { for (let i = 0; i < 5; i++) { await writeFile(join(l, `f${i}.txt`), String(i)) await writeFile(join(r, `f${i}.txt`), String(i)) } const res = await scanFolders(l, r, { maxFiles: 2 }) expect(res.truncated).toBe(true) expect(res.total).toBe(5) expect(res.entries.length).toBeLessThanOrEqual(2) } finally { await rm(l, { recursive: true, force: true }) await rm(r, { recursive: true, force: true }) } }) it('截断后不再深入子目录(凑满即停,total 为已遍历下限)', async () => { const l = await mkdtemp(join(tmpdir(), 'difflens-stop-l-')) const r = await mkdtemp(join(tmpdir(), 'difflens-stop-r-')) try { // 根目录 3 个文件 + 子目录 1 个文件,maxFiles=2:凑满后子目录不被遍历 // (readdir 目录内条目顺序不保证,但 3 个根文件计入 total 与顺序无关) for (let i = 0; i < 3; i++) { await writeFile(join(l, `f${i}.txt`), String(i)) await writeFile(join(r, `f${i}.txt`), String(i)) } await mkdir(join(l, 'sub'), { recursive: true }) await mkdir(join(r, 'sub'), { recursive: true }) await writeFile(join(l, 'sub', 'inner.txt'), 'x') await writeFile(join(r, 'sub', 'inner.txt'), 'x') const res = await scanFolders(l, r, { maxFiles: 2 }) expect(res.truncated).toBe(true) // 根目录 3 文件全部计入(当前目录统计完整),sub 未被深入 expect(res.total).toBe(3) expect(res.entries.some((e) => e.rel.startsWith('sub/'))).toBe(false) expect(res.entries.length).toBeLessThanOrEqual(2) } finally { await rm(l, { recursive: true, force: true }) await rm(r, { recursive: true, force: true }) } }) it('超过 maxContentBytes 的同大小文件:头部采样一致判 same 且带 approximate 标注', async () => { const l = await mkdtemp(join(tmpdir(), 'difflens-big-l-')) const r = await mkdtemp(join(tmpdir(), 'difflens-big-r-')) try { // 100KB 文件、maxContentBytes 压到 1KB:走采样路径;头部 8KB 一致 → same + approximate const head = Buffer.alloc(8 * 1024, 0x41) const tailL = Buffer.alloc(100 * 1024 - head.length, 0x01) const tailR = Buffer.alloc(100 * 1024 - head.length, 0x02) await writeFile(join(l, 'big.bin'), Buffer.concat([head, tailL])) await writeFile(join(r, 'big.bin'), Buffer.concat([head, tailR])) const res = await scanFolders(l, r, { maxContentBytes: 1024 }) expect(byRel(res.entries, 'big.bin')).toMatchObject({ status: 'same', approximate: true }) } finally { await rm(l, { recursive: true, force: true }) await rm(r, { recursive: true, force: true }) } }) it('采样头部不同直接判 different', async () => { const l = await mkdtemp(join(tmpdir(), 'difflens-head-l-')) const r = await mkdtemp(join(tmpdir(), 'difflens-head-r-')) try { await writeFile(join(l, 'big.bin'), Buffer.alloc(100 * 1024, 0x01)) await writeFile(join(r, 'big.bin'), Buffer.alloc(100 * 1024, 0x02)) const res = await scanFolders(l, r, { maxContentBytes: 1024 }) expect(byRel(res.entries, 'big.bin')?.status).toBe('different') } finally { await rm(l, { recursive: true, force: true }) await rm(r, { recursive: true, force: true }) } }) }) describe('scanFolders - 语义判等', () => { /** 语义判等注入:同步解码(UTF-8 直读)+ 文本扩展名白名单 */ const SEMANTIC = { maxBytes: SEMANTIC_MAX_BYTES, isTextFile: (rel: string): boolean => { const ext = rel.slice(rel.lastIndexOf('.') + 1).toLowerCase() return ['txt', 'md', 'json', 'ts'].includes(ext) }, decode: async (buf: Buffer): Promise => buf.toString('utf-8') } it('semanticEquals:空白/换行/空行差异判等,跨行重排判等,实质差异与大小写不判等', () => { expect(semanticEquals('a b', 'ab')).toBe(true) expect(semanticEquals('a\r\nb', 'a\nb')).toBe(true) expect(semanticEquals('a\n\n\nb', 'ab')).toBe(true) expect(semanticEquals('ab cd', 'ab\ncd')).toBe(true) expect(semanticEquals('abc', 'abd')).toBe(false) expect(semanticEquals('ABC', 'abc')).toBe(false) expect(semanticEquals('', '')).toBe(true) }) it('仅空白/换行差异的文本文件判为 semantic-same(同大小与不同大小均覆盖)', async () => { const l = await mkdtemp(join(tmpdir(), 'difflens-sem-l-')) const r = await mkdtemp(join(tmpdir(), 'difflens-sem-r-')) try { // 不同大小:行内空白差异(tab/空格/数量) await writeFile(join(l, 'ws.txt'), 'a\tb\nc d\n') await writeFile(join(r, 'ws.txt'), 'a b\nc d\n') // 不同大小:CRLF vs LF await writeFile(join(l, 'crlf.txt'), 'line1\r\nline2\r\n') await writeFile(join(r, 'crlf.txt'), 'line1\nline2\n') // 排版重排(跨行重组) await writeFile(join(l, 'reflow.txt'), 'hello world\n') await writeFile(join(r, 'reflow.txt'), 'hello\nworld\n') const res = await scanFolders(l, r, { semantic: SEMANTIC }) expect(byRel(res.entries, 'ws.txt')?.status).toBe('semantic-same') expect(byRel(res.entries, 'crlf.txt')?.status).toBe('semantic-same') expect(byRel(res.entries, 'reflow.txt')?.status).toBe('semantic-same') } finally { await rm(l, { recursive: true, force: true }) await rm(r, { recursive: true, force: true }) } }) it('实质内容差异与大小写差异仍判 different', async () => { const l = await mkdtemp(join(tmpdir(), 'difflens-sem2-l-')) const r = await mkdtemp(join(tmpdir(), 'difflens-sem2-r-')) try { await writeFile(join(l, 'real.txt'), 'value = 1\n') await writeFile(join(r, 'real.txt'), 'value = 2\n') await writeFile(join(l, 'case.txt'), 'Config\n') await writeFile(join(r, 'case.txt'), 'config\n') const res = await scanFolders(l, r, { semantic: SEMANTIC }) expect(byRel(res.entries, 'real.txt')?.status).toBe('different') expect(byRel(res.entries, 'case.txt')?.status).toBe('different') } finally { await rm(l, { recursive: true, force: true }) await rm(r, { recursive: true, force: true }) } }) it('非文本扩展名与超大小上限的条目不参与语义判等(保持字节级 different)', async () => { const l = await mkdtemp(join(tmpdir(), 'difflens-sem3-l-')) const r = await mkdtemp(join(tmpdir(), 'difflens-sem3-r-')) try { // 非文本扩展名(.bin 不在白名单):仅空白差异仍判 different await writeFile(join(l, 'data.bin'), 'a b\n') await writeFile(join(r, 'data.bin'), 'ab\n') // 大小超上限(注入 maxBytes=4):仅空白差异仍判 different await writeFile(join(l, 'big.txt'), 'aaaa bbbb\n') await writeFile(join(r, 'big.txt'), 'aaaabbbb\n') const res = await scanFolders(l, r, { semantic: { ...SEMANTIC, maxBytes: 4 } }) expect(byRel(res.entries, 'data.bin')?.status).toBe('different') expect(byRel(res.entries, 'big.txt')?.status).toBe('different') } finally { await rm(l, { recursive: true, force: true }) await rm(r, { recursive: true, force: true }) } }) it('未开启语义判等时空白差异保持字节级 different(行为与 0.6.2 一致)', async () => { const l = await mkdtemp(join(tmpdir(), 'difflens-sem4-l-')) const r = await mkdtemp(join(tmpdir(), 'difflens-sem4-r-')) try { await writeFile(join(l, 'ws.txt'), 'a b\n') await writeFile(join(r, 'ws.txt'), 'ab\n') const res = await scanFolders(l, r) expect(byRel(res.entries, 'ws.txt')?.status).toBe('different') } finally { await rm(l, { recursive: true, force: true }) await rm(r, { recursive: true, force: true }) } }) it('字节一致的文件不受语义开关影响(仍判 same,不触发解码)', async () => { const l = await mkdtemp(join(tmpdir(), 'difflens-sem5-l-')) const r = await mkdtemp(join(tmpdir(), 'difflens-sem5-r-')) let decoded = 0 try { await writeFile(join(l, 'same.txt'), 'same content\n') await writeFile(join(r, 'same.txt'), 'same content\n') const res = await scanFolders(l, r, { semantic: { ...SEMANTIC, decode: async (b) => { decoded++; return b.toString('utf-8') } } }) expect(byRel(res.entries, 'same.txt')?.status).toBe('same') expect(decoded).toBe(0) } finally { await rm(l, { recursive: true, force: true }) await rm(r, { recursive: true, force: true }) } }) it('单文件 IO 失败(模拟被锁)标记 unreadable,不影响其余条目判定', async () => { const l = await mkdtemp(join(tmpdir(), 'difflens-sem6-l-')) const r = await mkdtemp(join(tmpdir(), 'difflens-sem6-r-')) try { // 正常差异文件 + 失败文件(decode 抛异常模拟读取后置失败,如 Windows 文件锁) await writeFile(join(l, 'ok.txt'), 'a\n') await writeFile(join(r, 'ok.txt'), 'b\n') await writeFile(join(l, 'locked.txt'), 'x y\n') await writeFile(join(r, 'locked.txt'), 'xy\n') const res = await scanFolders(l, r, { semantic: { ...SEMANTIC, decode: async (b) => { if (b.toString('utf-8').includes('x')) throw new Error('EBUSY') return b.toString('utf-8') } } }) expect(byRel(res.entries, 'ok.txt')?.status).toBe('different') expect(byRel(res.entries, 'locked.txt')?.status).toBe('unreadable') } finally { await rm(l, { recursive: true, force: true }) await rm(r, { recursive: true, force: true }) } }) }) describe('scanFolders - 受控并发内容比对', () => { it('跨多个并发批次的混合状态全部判定正确且排序不变', async () => { const l = await mkdtemp(join(tmpdir(), 'difflens-conc-l-')) const r = await mkdtemp(join(tmpdir(), 'difflens-conc-r-')) try { // 40 个大小一致的条目 > 2×SCAN_CONCURRENCY(16),覆盖至少 3 个并发批次; // 状态混排:偶数 same / 奇数 different / 尾部两个单侧 const total = 40 for (let i = 0; i < total; i++) { const same = i % 2 === 0 const content = same ? `c${i}` : `L${i}` const contentR = same ? `c${i}` : `R${i}` await writeFile(join(l, `f${String(i).padStart(2, '0')}.txt`), content) await writeFile(join(r, `f${String(i).padStart(2, '0')}.txt`), contentR) } await writeFile(join(l, 'zz-only-left.txt'), 'L') await writeFile(join(r, 'zz-only-right.txt'), 'R') const res = await scanFolders(l, r) expect(res.entries).toHaveLength(total + 2) // 每个条目状态正确(并发写回槽位不丢不错) for (let i = 0; i < total; i++) { const rel = `f${String(i).padStart(2, '0')}.txt` expect(byRel(res.entries, rel)?.status).toBe(i % 2 === 0 ? 'same' : 'different') } expect(byRel(res.entries, 'zz-only-left.txt')?.status).toBe('left-only') expect(byRel(res.entries, 'zz-only-right.txt')?.status).toBe('right-only') // 排序仍为字典序(输出顺序与并发执行顺序无关) const rels = res.entries.map((e) => e.rel) expect([...rels].sort()).toEqual(rels) } finally { await rm(l, { recursive: true, force: true }) await rm(r, { recursive: true, force: true }) } }) })