import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { inspectProject, listContent, openProject, readYaml, resolveWithin, stats, writeYaml } from "../src/project.js"; import { newProject } from "../src/template.js"; async function tmpDir(): Promise { return mkdtemp(join(tmpdir(), "editor-project-")); } describe("openProject", () => { it("accepts a folder with content/ and returns project info", async () => { const dir = await tmpDir(); try { await mkdir(join(dir, "content")); const project = await openProject(dir); expect(project.root).toBe(dir); expect(project.name).toBe(dir.split("/").pop()); expect(project.hasContent).toBe(true); expect(project.hasMods).toBe(false); expect(project.projectYaml).toBeNull(); } finally { await rm(dir, { recursive: true, force: true }); } }); it("rejects a folder without content/", async () => { const dir = await tmpDir(); try { await expect(openProject(dir)).rejects.toThrow(/missing content/); } finally { await rm(dir, { recursive: true, force: true }); } }); it("uses project.yaml name when present", async () => { const dir = await tmpDir(); try { await mkdir(join(dir, "content")); await writeFile(join(dir, "project.yaml"), "name: My Game\n", "utf8"); const project = await openProject(dir); expect(project.name).toBe("My Game"); expect(project.projectYaml?.name).toBe("My Game"); } finally { await rm(dir, { recursive: true, force: true }); } }); it("tolerates a malformed project.yaml", async () => { const dir = await tmpDir(); try { await mkdir(join(dir, "content")); await writeFile(join(dir, "project.yaml"), "name: [broken\n", "utf8"); const project = await openProject(dir); expect(project.name).toBe(dir.split("/").pop()); expect(project.projectYaml).toBeNull(); expect(project.projectYamlError).toBeDefined(); } finally { await rm(dir, { recursive: true, force: true }); } }); }); describe("listContent and stats", () => { it("lists kinds with counts and relative file paths on a generated project", async () => { const dir = await tmpDir(); try { const project = await newProject("Test Farm", dir); const listing = await listContent(project.root); const byKind = new Map(listing.kinds.map((k) => [k.kind, k])); expect(byKind.get("map")?.count).toBe(1); expect(byKind.get("item")?.count).toBe(3); expect(byKind.get("crop")?.count).toBe(1); expect(byKind.get("time")?.count).toBe(1); expect(byKind.get("weather")?.count).toBe(1); expect(byKind.get("interact")?.count).toBe(1); expect(byKind.get("scene")?.count).toBe(1); const hoe = byKind.get("item")?.files.find((f) => f.name === "hoe.yaml"); expect(hoe?.path).toBe("content/items/hoe.yaml"); const time = byKind.get("time")?.files[0]; expect(time?.path).toBe("content/time.yaml"); expect(listing.mods).toEqual([]); } finally { await rm(dir, { recursive: true, force: true }); } }); it("returns stats with per-kind counts", async () => { const dir = await tmpDir(); try { const project = await newProject("Stats Farm", dir); const result = await stats(project.root); expect(result.kinds.map).toBe(1); expect(result.kinds.item).toBe(3); expect(result.total).toBe(9); expect(result.mods).toBe(0); expect(result.assets).toBe(0); } finally { await rm(dir, { recursive: true, force: true }); } }); it("counts mods when a mods/ dir has entries", async () => { const dir = await tmpDir(); try { await mkdir(join(dir, "content"), { recursive: true }); await mkdir(join(dir, "mods", "my-mod"), { recursive: true }); await writeFile(join(dir, "mods", "my-mod", "index.mjs"), "export const hooks = {}\n", "utf8"); const listing = await listContent(dir); expect(listing.mods).toEqual(["my-mod"]); expect((await stats(dir)).mods).toBe(1); } finally { await rm(dir, { recursive: true, force: true }); } }); }); describe("resolveWithin", () => { it("accepts paths inside the root", () => { expect(resolveWithin("/a/b", "content/maps/farm.yaml")).toBe("/a/b/content/maps/farm.yaml"); expect(resolveWithin("/a/b", ".")).toBe("/a/b"); }); it("rejects parent traversal and absolute escapes", () => { expect(() => resolveWithin("/a/b", "../secret.yaml")).toThrow(/escapes project root/); expect(() => resolveWithin("/a/b", "content/../../x.yaml")).toThrow(/escapes project root/); expect(() => resolveWithin("/a/b", "/etc/passwd")).toThrow(/escapes project root/); }); }); describe("readYaml and writeYaml", () => { it("roundtrips yaml content and creates nested directories", async () => { const dir = await tmpDir(); try { await mkdir(join(dir, "content"), { recursive: true }); await writeYaml(dir, "content/items/hoe.yaml", "id: hoe\n"); expect(await readYaml(dir, "content/items/hoe.yaml")).toBe("id: hoe\n"); } finally { await rm(dir, { recursive: true, force: true }); } }); it("rejects non-yaml paths", async () => { const dir = await tmpDir(); try { await expect(writeYaml(dir, "content/notes.txt", "x")).rejects.toThrow(/not a yaml path/); } finally { await rm(dir, { recursive: true, force: true }); } }); it("rejects traversal in read and write", async () => { const dir = await tmpDir(); try { await expect(writeYaml(dir, "../evil.yaml", "x")).rejects.toThrow(/escapes project root/); await expect(readYaml(dir, "content/../../evil.yaml")).rejects.toThrow(/escapes project root/); } finally { await rm(dir, { recursive: true, force: true }); } }); }); describe("inspectProject", () => { it("inspects a folder without requiring content/", async () => { const dir = await tmpDir(); try { const project = await inspectProject(dir); expect(project.hasContent).toBe(false); expect(project.hasMods).toBe(false); } finally { await rm(dir, { recursive: true, force: true }); } }); });