378 lines
17 KiB
TypeScript
378 lines
17 KiB
TypeScript
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 })
|
||
}
|
||
})
|
||
})
|