project.test.ts 6.9 KB

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