import { type E2EWorld } from "./world" import { snapshotLocalReal, snapshotRemoteReal } from "./assert" import { type SyncMessage } from "../../../src/types" import { expect } from "vitest" /** * Drive exactly one synchronization cycle and return the messages it emitted. * * `resetCache` (default true) clears the in-memory local+remote tree caches and rolls the * local-change timestamp back, so the cycle re-reads both trees from scratch and immediately passes the * change-debounce — the deterministic way to make a cycle pick up a mutation we just applied. Pass * `false` to exercise the warm-cache / deviceId "unchanged" path (e.g. proving a settled sync no-ops). */ export async function cycle(world: E2EWorld, options: { resetCache?: boolean } = {}): Promise { const mark = world.messages.length if (options.resetCache ?? true) { world.worker.resetCache(world.syncPair.uuid) } await world.sync.runCycle() return world.messages.slice(mark) } /** * Run cycles until the world stops changing (a fixpoint of the combined local+remote structure/size * snapshot) or `maxCycles` is hit. Real backends settle a small tree in 2-3 cycles (transfer, then a * confirming no-op); the cap is a safety net, not the expected path. */ export async function settle(world: E2EWorld, options: { maxCycles?: number; resetCache?: boolean } = {}): Promise { const maxCycles = options.maxCycles ?? 8 let previous = "" for (let i = 0; i < maxCycles; i++) { await cycle(world, { resetCache: options.resetCache ?? true }) const [local, remote] = await Promise.all([snapshotLocalReal(world), snapshotRemoteReal(world)]) const signature = JSON.stringify([local, remote]) if (signature === previous) { return } previous = signature } } const FILE_TRANSFER_OPS = ["upload", "uploadFile", "download", "downloadFile"] as const /** The `of` discriminators of every "transfer" message in the stream. */ export function transferKinds(messages: SyncMessage[]): string[] { return messages.filter((message): message is Extract => message.type === "transfer").map(message => message.data.of) } /** Only the discriminators that represent an actual file transfer (not a dir/rename op). */ 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 — exactly what a wrong * post-restart base re-derivation would emit — slips past it. A genuine no-op cycle starts no tasks and so * emits no transfer messages of any kind, making this empty. */ export function allOps(messages: SyncMessage[]): string[] { return [...new Set(transferKinds(messages))].sort() } /** All messages of a given `type`. */ export function messagesOfType(messages: SyncMessage[], type: T): Extract[] { return messages.filter((message): message is Extract => message.type === type) } /** * Assert the local and remote trees are identical. With `withContent` (default) every file is hashed on * both sides (downloading the remote copy), so this proves real content identity, not just structure. */ export async function expectConverged(world: E2EWorld, options: { withContent?: boolean } = {}): Promise { const withContent = options.withContent ?? true const [local, remote] = await Promise.all([snapshotLocalReal(world, { withContent }), snapshotRemoteReal(world, { withContent })]) expect(local).toEqual(remote) }