Files
DiffLens/src/main/folderScan.test.ts
T

378 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> {
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<string> => 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 })
}
})
})