project.test.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
  2. import { tmpdir } from "node:os";
  3. import { join } from "node:path";
  4. import { describe, expect, it } from "vitest";
  5. import { inspectProject, listContent, openProject, readYaml, resolveWithin, stats, writeYaml } from "../src/project.js";
  6. import { newProject } from "../src/template.js";
  7. async function tmpDir(): Promise<string> {
  8. return mkdtemp(join(tmpdir(), "editor-project-"));
  9. }
  10. describe("openProject", () => {
  11. it("accepts a folder with content/ and returns project info", async () => {
  12. const dir = await tmpDir();
  13. try {
  14. await mkdir(join(dir, "content"));
  15. const project = await openProject(dir);
  16. expect(project.root).toBe(dir);
  17. expect(project.name).toBe(dir.split("/").pop());
  18. expect(project.hasContent).toBe(true);
  19. expect(project.hasMods).toBe(false);
  20. expect(project.projectYaml).toBeNull();
  21. } finally {
  22. await rm(dir, { recursive: true, force: true });
  23. }
  24. });
  25. it("rejects a folder without content/", async () => {
  26. const dir = await tmpDir();
  27. try {
  28. await expect(openProject(dir)).rejects.toThrow(/missing content/);
  29. } finally {
  30. await rm(dir, { recursive: true, force: true });
  31. }
  32. });
  33. it("uses project.yaml name when present", async () => {
  34. const dir = await tmpDir();
  35. try {
  36. await mkdir(join(dir, "content"));
  37. await writeFile(join(dir, "project.yaml"), "name: My Game\n", "utf8");
  38. const project = await openProject(dir);
  39. expect(project.name).toBe("My Game");
  40. expect(project.projectYaml?.name).toBe("My Game");
  41. } finally {
  42. await rm(dir, { recursive: true, force: true });
  43. }
  44. });
  45. it("tolerates a malformed project.yaml", async () => {
  46. const dir = await tmpDir();
  47. try {
  48. await mkdir(join(dir, "content"));
  49. await writeFile(join(dir, "project.yaml"), "name: [broken\n", "utf8");
  50. const project = await openProject(dir);
  51. expect(project.name).toBe(dir.split("/").pop());
  52. expect(project.projectYaml).toBeNull();
  53. expect(project.projectYamlError).toBeDefined();
  54. } finally {
  55. await rm(dir, { recursive: true, force: true });
  56. }
  57. });
  58. });
  59. describe("listContent and stats", () => {
  60. it("lists kinds with counts and relative file paths on a generated project", async () => {
  61. const dir = await tmpDir();
  62. try {
  63. const project = await newProject("Test Farm", dir);
  64. const listing = await listContent(project.root);
  65. const byKind = new Map(listing.kinds.map((k) => [k.kind, k]));
  66. expect(byKind.get("map")?.count).toBe(1);
  67. expect(byKind.get("item")?.count).toBe(3);
  68. expect(byKind.get("crop")?.count).toBe(1);
  69. expect(byKind.get("time")?.count).toBe(1);
  70. expect(byKind.get("weather")?.count).toBe(1);
  71. expect(byKind.get("interact")?.count).toBe(1);
  72. expect(byKind.get("scene")?.count).toBe(1);
  73. const hoe = byKind.get("item")?.files.find((f) => f.name === "hoe.yaml");
  74. expect(hoe?.path).toBe("content/items/hoe.yaml");
  75. const time = byKind.get("time")?.files[0];
  76. expect(time?.path).toBe("content/time.yaml");
  77. expect(listing.mods).toEqual([]);
  78. } finally {
  79. await rm(dir, { recursive: true, force: true });
  80. }
  81. });
  82. it("returns stats with per-kind counts", async () => {
  83. const dir = await tmpDir();
  84. try {
  85. const project = await newProject("Stats Farm", dir);
  86. const result = await stats(project.root);
  87. expect(result.kinds.map).toBe(1);
  88. expect(result.kinds.item).toBe(3);
  89. expect(result.total).toBe(9);
  90. expect(result.mods).toBe(0);
  91. expect(result.assets).toBe(0);
  92. } finally {
  93. await rm(dir, { recursive: true, force: true });
  94. }
  95. });
  96. it("counts mods when a mods/ dir has entries", async () => {
  97. const dir = await tmpDir();
  98. try {
  99. await mkdir(join(dir, "content"), { recursive: true });
  100. await mkdir(join(dir, "mods", "my-mod"), { recursive: true });
  101. await writeFile(join(dir, "mods", "my-mod", "index.mjs"), "export const hooks = {}\n", "utf8");
  102. const listing = await listContent(dir);
  103. expect(listing.mods).toEqual(["my-mod"]);
  104. expect((await stats(dir)).mods).toBe(1);
  105. } finally {
  106. await rm(dir, { recursive: true, force: true });
  107. }
  108. });
  109. });
  110. describe("resolveWithin", () => {
  111. it("accepts paths inside the root", () => {
  112. expect(resolveWithin("/a/b", "content/maps/farm.yaml")).toBe("/a/b/content/maps/farm.yaml");
  113. expect(resolveWithin("/a/b", ".")).toBe("/a/b");
  114. });
  115. it("rejects parent traversal and absolute escapes", () => {
  116. expect(() => resolveWithin("/a/b", "../secret.yaml")).toThrow(/escapes project root/);
  117. expect(() => resolveWithin("/a/b", "content/../../x.yaml")).toThrow(/escapes project root/);
  118. expect(() => resolveWithin("/a/b", "/etc/passwd")).toThrow(/escapes project root/);
  119. });
  120. });
  121. describe("readYaml and writeYaml", () => {
  122. it("roundtrips yaml content and creates nested directories", async () => {
  123. const dir = await tmpDir();
  124. try {
  125. await mkdir(join(dir, "content"), { recursive: true });
  126. await writeYaml(dir, "content/items/hoe.yaml", "id: hoe\n");
  127. expect(await readYaml(dir, "content/items/hoe.yaml")).toBe("id: hoe\n");
  128. } finally {
  129. await rm(dir, { recursive: true, force: true });
  130. }
  131. });
  132. it("rejects non-yaml paths", async () => {
  133. const dir = await tmpDir();
  134. try {
  135. await expect(writeYaml(dir, "content/notes.txt", "x")).rejects.toThrow(/not a yaml path/);
  136. } finally {
  137. await rm(dir, { recursive: true, force: true });
  138. }
  139. });
  140. it("rejects traversal in read and write", async () => {
  141. const dir = await tmpDir();
  142. try {
  143. await expect(writeYaml(dir, "../evil.yaml", "x")).rejects.toThrow(/escapes project root/);
  144. await expect(readYaml(dir, "content/../../evil.yaml")).rejects.toThrow(/escapes project root/);
  145. } finally {
  146. await rm(dir, { recursive: true, force: true });
  147. }
  148. });
  149. });
  150. describe("inspectProject", () => {
  151. it("inspects a folder without requiring content/", async () => {
  152. const dir = await tmpDir();
  153. try {
  154. const project = await inspectProject(dir);
  155. expect(project.hasContent).toBe(false);
  156. expect(project.hasMods).toBe(false);
  157. } finally {
  158. await rm(dir, { recursive: true, force: true });
  159. }
  160. });
  161. });