import { execFileSync } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { detectTargetSnapshot, isSkipped, parseNameStatus, type ReconcileManifest, targetCandidates, } from "./reconcile"; describe("parseNameStatus", () => { it("splits plain and rename entries", () => { expect(parseNameStatus("M\tsections/A.tsx\nR094\tislands/B.tsx\tislands/C.tsx\n")) .toEqual([ { status: "M", path: "sections/A.tsx" }, { status: "R094", path: "islands/C.tsx", oldPath: "islands/B.tsx" }, ]); }); }); describe("isSkipped", () => { it("drops CMS content, lockfiles and binaries but keeps source", () => { expect(isSkipped(".deco/blocks/pages-home.json")).toBe(true); expect(isSkipped("deno.lock")).toBe(true); expect(isSkipped("static/logo.png")).toBe(true); expect(isSkipped("sections/Header.tsx")).toBe(false); }); it("drops what the migration deletes — no target file to port into", () => { expect(isSkipped("fresh.gen.ts")).toBe(true); expect(isSkipped("manifest.gen.ts")).toBe(true); expect(isSkipped("deno.json")).toBe(true); expect(isSkipped("sdk/cart/vtex.ts")).toBe(true); // routes/ is rescaffolded but a NEW upstream route is a real change — keep it. expect(isSkipped("routes/blog.tsx")).toBe(false); expect(isSkipped("redirects.csv")).toBe(false); }); }); describe("targetCandidates", () => { it("ranks the conventional guess first, keeps other basename matches", () => { const byBasename = new Map([ ["Cart.tsx", ["src/sections/Cart.tsx", "src/components/Cart.tsx"]], ]); expect(targetCandidates("islands/Cart.tsx", byBasename)).toEqual([ "src/components/Cart.tsx", "src/sections/Cart.tsx", ]); }); it("returns nothing when the file does not exist on the target", () => { expect(targetCandidates("sections/New.tsx", new Map())).toEqual([]); }); }); describe("detectTargetSnapshot", () => { it("takes the OLDEST add — git log is newest-first, re-adds must not win", () => { expect(detectTargetSnapshot("bbb\naaa\n")).toBe("aaa"); expect(detectTargetSnapshot("")).toBeUndefined(); }); }); describe("reconcile end to end", () => { let tmp: string; const git = (cwd: string, ...args: string[]) => execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8" }).trim(); const write = (repo: string, rel: string, body: string) => { fs.mkdirSync(path.join(repo, path.dirname(rel)), { recursive: true }); fs.writeFileSync(path.join(repo, rel), body); }; const commit = (repo: string, msg: string) => { git(repo, "add", "-A"); git(repo, "commit", "-m", msg); return git(repo, "rev-parse", "HEAD"); }; const init = (name: string) => { const repo = path.join(tmp, name); fs.mkdirSync(repo, { recursive: true }); git(repo, "init", "-q", "-b", "main"); git(repo, "config", "user.email", "t@t.t"); git(repo, "config", "user.name", "t"); return repo; }; beforeEach(() => { // realpath: macOS /var → /private/var, which git resolves and we compare against. tmp = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "reconcile-")); }); afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); it("auto-detects the target snapshot, emits one patch per file, flags hand-fixes", () => { const source = init("source"); write(source, "sections/Header.tsx", "export default () =>