feat: 解码 worker 化、Playwright E2E 测试体系与文件夹对比(0.6.0)
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { readdir, stat, readFile, open } from 'fs/promises'
|
||||
import { join, relative, sep } from 'path'
|
||||
|
||||
/**
|
||||
* 文件夹对比的纯逻辑模块(仅依赖 node:fs,vitest 以真实临时目录直接测试)。
|
||||
* 递归枚举两侧目录(跳过 symlink 防环)、按相对路径对齐、字节级内容判定;
|
||||
* 全部 IO 走异步 fs(libuv 线程池),不阻塞主进程事件循环。
|
||||
*/
|
||||
|
||||
/** 单个文件条目的对比状态 */
|
||||
export type FolderEntryStatus = 'same' | 'different' | 'left-only' | 'right-only'
|
||||
|
||||
export interface FolderEntry {
|
||||
/** 相对路径(统一 / 分隔,含子目录前缀) */
|
||||
rel: string
|
||||
status: FolderEntryStatus
|
||||
/** 左侧文件字节数;该侧不存在为 null */
|
||||
leftSize: number | null
|
||||
/** 右侧文件字节数;该侧不存在为 null */
|
||||
rightSize: number | null
|
||||
/** 超过全量比对上限、仅采样头部判定的近似结果(status 为 same 时可能出现) */
|
||||
approximate?: boolean
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
/** 全部条目(按相对路径字典序排序) */
|
||||
entries: FolderEntry[]
|
||||
/** 因超出文件数量上限被截断(条目不完整,界面提示人工确认) */
|
||||
truncated: boolean
|
||||
/** 枚举发现的文件总数(截断时为上限值) */
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ScanOptions {
|
||||
/** 单侧文件数量上限(默认 10000) */
|
||||
maxFiles?: number
|
||||
/** 全量字节比对的文件大小上限(默认 10MB,超出仅采样头部 8KB 近似判定) */
|
||||
maxContentBytes?: number
|
||||
}
|
||||
|
||||
export const FOLDER_MAX_FILES = 10000
|
||||
export const FOLDER_MAX_CONTENT_BYTES = 10 * 1024 * 1024
|
||||
/** 近似判定的采样头部字节数 */
|
||||
const SAMPLE_BYTES = 8 * 1024
|
||||
|
||||
/** 递归枚举目录下全部普通文件(跳过子目录与 symlink),返回相对路径 → 大小 */
|
||||
async function listFiles(
|
||||
root: string,
|
||||
maxFiles: number
|
||||
): Promise<{ files: Map<string, number>; truncated: boolean; total: number }> {
|
||||
const dirents = await readdir(root, { recursive: true, withFileTypes: true })
|
||||
const rels: string[] = []
|
||||
let total = 0
|
||||
let truncated = false
|
||||
for (const d of dirents) {
|
||||
if (!d.isFile()) continue
|
||||
total++
|
||||
if (rels.length >= maxFiles) {
|
||||
truncated = true
|
||||
continue
|
||||
}
|
||||
// parentPath 为 Node 20.12+ 的 Dirent 属性(旧名 path)
|
||||
rels.push(relative(root, join(d.parentPath, d.name)).split(sep).join('/'))
|
||||
}
|
||||
// 并行 stat(libuv 线程池排队,文件数量受 maxFiles 约束)
|
||||
const stats = await Promise.all(rels.map((rel) => stat(join(root, rel))))
|
||||
const files = new Map<string, number>()
|
||||
rels.forEach((rel, i) => files.set(rel, stats[i].size))
|
||||
return { files, truncated, total }
|
||||
}
|
||||
|
||||
/** 读取文件头部指定字节数(近似判定采样) */
|
||||
async function readHead(path: string, bytes: number): Promise<Buffer> {
|
||||
const fh = await open(path, 'r')
|
||||
try {
|
||||
const buf = Buffer.alloc(bytes)
|
||||
const { bytesRead } = await fh.read(buf, 0, bytes, 0)
|
||||
return buf.subarray(0, bytesRead)
|
||||
} finally {
|
||||
await fh.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对比两个文件夹:按相对路径对齐条目并判定内容异同。
|
||||
* 判定规则:单侧缺失 → only;大小不同 → different;
|
||||
* 大小相同且 ≤ maxContentBytes → 全量字节比对;超过上限 → 头部 8KB 采样近似判定。
|
||||
* 目录不存在/不可读时抛出异常,由 IPC 调用方转为错误结果。
|
||||
*/
|
||||
export async function scanFolders(
|
||||
leftDir: string,
|
||||
rightDir: string,
|
||||
options: ScanOptions = {}
|
||||
): Promise<ScanResult> {
|
||||
const maxFiles = options.maxFiles ?? FOLDER_MAX_FILES
|
||||
const maxContentBytes = options.maxContentBytes ?? FOLDER_MAX_CONTENT_BYTES
|
||||
const left = await listFiles(leftDir, maxFiles)
|
||||
const right = await listFiles(rightDir, maxFiles)
|
||||
|
||||
const rels = [...new Set([...left.files.keys(), ...right.files.keys()])].sort()
|
||||
const entries: FolderEntry[] = []
|
||||
for (const rel of rels) {
|
||||
const lSize = left.files.get(rel)
|
||||
const rSize = right.files.get(rel)
|
||||
if (lSize === undefined) {
|
||||
entries.push({ rel, status: 'right-only', leftSize: null, rightSize: rSize ?? null })
|
||||
} else if (rSize === undefined) {
|
||||
entries.push({ rel, status: 'left-only', leftSize: lSize, rightSize: null })
|
||||
} else if (lSize !== rSize) {
|
||||
entries.push({ rel, status: 'different', leftSize: lSize, rightSize: rSize })
|
||||
} else if (lSize <= maxContentBytes) {
|
||||
const lBuf = await readFile(join(leftDir, rel))
|
||||
const rBuf = await readFile(join(rightDir, rel))
|
||||
entries.push(
|
||||
lBuf.equals(rBuf)
|
||||
? { rel, status: 'same', leftSize: lSize, rightSize: rSize }
|
||||
: { rel, status: 'different', leftSize: lSize, rightSize: rSize }
|
||||
)
|
||||
} else {
|
||||
// 超过全量比对上限:头部采样近似判定,结果带 approximate 标注
|
||||
const lHead = await readHead(join(leftDir, rel), SAMPLE_BYTES)
|
||||
const rHead = await readHead(join(rightDir, rel), SAMPLE_BYTES)
|
||||
entries.push(
|
||||
lHead.equals(rHead)
|
||||
? { rel, status: 'same', leftSize: lSize, rightSize: rSize, approximate: true }
|
||||
: { rel, status: 'different', leftSize: lSize, rightSize: rSize }
|
||||
)
|
||||
}
|
||||
}
|
||||
return { entries, truncated: left.truncated || right.truncated, total: Math.max(left.total, right.total) }
|
||||
}
|
||||
Reference in New Issue
Block a user