import pathModule from "path" import crypto from "crypto" import { LOCAL_TRASH_NAME } from "../../src/constants" import { type SyncMessage } from "../../src/types" import { type World } from "./world" export type SnapshotEntry = { type: "file" | "directory"; size: number; mtimeSec: number; contentHash: string } /** * The normalized assertion unit: `path → { type, size, mtime(sec), contentHash }`, excluding the * local trash directory. Local and remote snapshots are directly comparable — same content yields * the same sha512 hash and the same whole-second mtime on both sides. */ export type WorldSnapshot = Record function sha512Hex(data: Buffer): string { return crypto.createHash("sha512").update(Uint8Array.from(data)).digest("hex") } /** * Normalized snapshot of the local sync directory (relative POSIX paths), excluding the local trash. */ export function snapshotLocal(world: World): WorldSnapshot { const result: WorldSnapshot = {} const ifs = world.vfs.ifs const root = world.localPath const walk = (directory: string): void => { let entries: string[] try { entries = ifs.readdirSync(directory) as string[] } catch { return } for (const entry of entries) { if (entry === LOCAL_TRASH_NAME) { continue } const full = pathModule.posix.join(directory, entry) const relativePath = full.slice(root.length) const stats = ifs.statSync(full) if (stats.isDirectory()) { result[relativePath] = { type: "directory", size: 0, mtimeSec: 0, contentHash: "" } walk(full) } else if (stats.isFile()) { const content = ifs.readFileSync(full) as Buffer result[relativePath] = { type: "file", size: stats.size, mtimeSec: Math.floor(stats.mtimeMs / 1000), contentHash: sha512Hex(content) } } } } walk(root) return result } /** * Normalized snapshot of the live remote tree (relative POSIX paths). */ export function snapshotRemote(world: World): WorldSnapshot { return world.cloud.controls.snapshot() } /** All messages of a given `type`. */ export function messagesOfType(messages: SyncMessage[], type: T): Extract[] { return messages.filter((message): message is Extract => message.type === type) } /** Count messages of a given `type`. */ export function countMessages(messages: SyncMessage[], type: SyncMessage["type"]): number { return messages.filter(message => message.type === type).length } /** The `of` discriminators of every "transfer" message (e.g. "upload", "downloadFile", ...). */ export function transferKinds(messages: SyncMessage[]): string[] { return messagesOfType(messages, "transfer").map(message => message.data.of) } /** The transfer `of` discriminators that represent an actual file transfer (not a dir/rename op). */ export const FILE_TRANSFER_OPS = ["upload", "uploadFile", "download", "downloadFile"] as const /** The actual file-transfer operations seen in a message stream (upload/download, queued..finished). */ export function transferOps(messages: SyncMessage[]): string[] { return transferKinds(messages).filter(kind => (FILE_TRANSFER_OPS as readonly string[]).includes(kind)) } /** * Every operation kind in the stream — file transfers AND directory creates, deletes, and renames (the * full set of `transfer.of` discriminators), deduplicated and sorted for readable failure output. * * `expect(allOps(messages)).toEqual([])` is the assertion for a COMPLETE no-op. transferOps() sees only * file up/downloads, so a cycle that spuriously renamed, deleted, or mkdir'd a path slips past it; allOps * catches it. Mirror of the e2e harness helper of the same name. */ export function allOps(messages: SyncMessage[]): string[] { return [...new Set(transferKinds(messages))].sort() } /** Whether any actual file transfer (upload/download) occurred in the message stream. */ export function hadTransfers(messages: SyncMessage[]): boolean { return transferOps(messages).length > 0 }