project.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. import { describe, expect, mock, test } from "bun:test"
  2. import { Project } from "../../src/project/project"
  3. import { Log } from "../../src/util/log"
  4. import { $ } from "bun"
  5. import path from "path"
  6. import { tmpdir } from "../fixture/fixture"
  7. import { GlobalBus } from "../../src/bus/global"
  8. Log.init({ print: false })
  9. const gitModule = await import("../../src/util/git")
  10. const originalGit = gitModule.git
  11. type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail"
  12. let mode: Mode = "none"
  13. mock.module("../../src/util/git", () => ({
  14. git: (args: string[], opts: { cwd: string; env?: Record<string, string> }) => {
  15. const cmd = ["git", ...args].join(" ")
  16. if (
  17. mode === "rev-list-fail" &&
  18. cmd.includes("git rev-list") &&
  19. cmd.includes("--max-parents=0") &&
  20. cmd.includes("--all")
  21. ) {
  22. return Promise.resolve({
  23. exitCode: 128,
  24. text: () => Promise.resolve(""),
  25. stdout: Buffer.from(""),
  26. stderr: Buffer.from("fatal"),
  27. })
  28. }
  29. if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) {
  30. return Promise.resolve({
  31. exitCode: 128,
  32. text: () => Promise.resolve(""),
  33. stdout: Buffer.from(""),
  34. stderr: Buffer.from("fatal"),
  35. })
  36. }
  37. if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) {
  38. return Promise.resolve({
  39. exitCode: 128,
  40. text: () => Promise.resolve(""),
  41. stdout: Buffer.from(""),
  42. stderr: Buffer.from("fatal"),
  43. })
  44. }
  45. return originalGit(args, opts)
  46. },
  47. }))
  48. async function withMode(next: Mode, run: () => Promise<void>) {
  49. const prev = mode
  50. mode = next
  51. try {
  52. await run()
  53. } finally {
  54. mode = prev
  55. }
  56. }
  57. async function loadProject() {
  58. return (await import("../../src/project/project")).Project
  59. }
  60. describe("Project.fromDirectory", () => {
  61. test("should handle git repository with no commits", async () => {
  62. const p = await loadProject()
  63. await using tmp = await tmpdir()
  64. await $`git init`.cwd(tmp.path).quiet()
  65. const { project } = await p.fromDirectory(tmp.path)
  66. expect(project).toBeDefined()
  67. expect(project.id).toBe("global")
  68. expect(project.vcs).toBe("git")
  69. expect(project.worktree).toBe(tmp.path)
  70. const opencodeFile = path.join(tmp.path, ".git", "opencode")
  71. const fileExists = await Bun.file(opencodeFile).exists()
  72. expect(fileExists).toBe(false)
  73. })
  74. test("should handle git repository with commits", async () => {
  75. const p = await loadProject()
  76. await using tmp = await tmpdir({ git: true })
  77. const { project } = await p.fromDirectory(tmp.path)
  78. expect(project).toBeDefined()
  79. expect(project.id).not.toBe("global")
  80. expect(project.vcs).toBe("git")
  81. expect(project.worktree).toBe(tmp.path)
  82. const opencodeFile = path.join(tmp.path, ".git", "opencode")
  83. const fileExists = await Bun.file(opencodeFile).exists()
  84. expect(fileExists).toBe(true)
  85. })
  86. test("keeps git vcs when rev-list exits non-zero with empty output", async () => {
  87. const p = await loadProject()
  88. await using tmp = await tmpdir()
  89. await $`git init`.cwd(tmp.path).quiet()
  90. await withMode("rev-list-fail", async () => {
  91. const { project } = await p.fromDirectory(tmp.path)
  92. expect(project.vcs).toBe("git")
  93. expect(project.id).toBe("global")
  94. expect(project.worktree).toBe(tmp.path)
  95. })
  96. })
  97. test("keeps git vcs when show-toplevel exits non-zero with empty output", async () => {
  98. const p = await loadProject()
  99. await using tmp = await tmpdir({ git: true })
  100. await withMode("top-fail", async () => {
  101. const { project, sandbox } = await p.fromDirectory(tmp.path)
  102. expect(project.vcs).toBe("git")
  103. expect(project.worktree).toBe(tmp.path)
  104. expect(sandbox).toBe(tmp.path)
  105. })
  106. })
  107. test("keeps git vcs when git-common-dir exits non-zero with empty output", async () => {
  108. const p = await loadProject()
  109. await using tmp = await tmpdir({ git: true })
  110. await withMode("common-dir-fail", async () => {
  111. const { project, sandbox } = await p.fromDirectory(tmp.path)
  112. expect(project.vcs).toBe("git")
  113. expect(project.worktree).toBe(tmp.path)
  114. expect(sandbox).toBe(tmp.path)
  115. })
  116. })
  117. })
  118. describe("Project.fromDirectory with worktrees", () => {
  119. test("should set worktree to root when called from root", async () => {
  120. const p = await loadProject()
  121. await using tmp = await tmpdir({ git: true })
  122. const { project, sandbox } = await p.fromDirectory(tmp.path)
  123. expect(project.worktree).toBe(tmp.path)
  124. expect(sandbox).toBe(tmp.path)
  125. expect(project.sandboxes).not.toContain(tmp.path)
  126. })
  127. test("should set worktree to root when called from a worktree", async () => {
  128. const p = await loadProject()
  129. await using tmp = await tmpdir({ git: true })
  130. const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree")
  131. try {
  132. await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet()
  133. const { project, sandbox } = await p.fromDirectory(worktreePath)
  134. expect(project.worktree).toBe(tmp.path)
  135. expect(sandbox).toBe(worktreePath)
  136. expect(project.sandboxes).toContain(worktreePath)
  137. expect(project.sandboxes).not.toContain(tmp.path)
  138. } finally {
  139. await $`git worktree remove ${worktreePath}`
  140. .cwd(tmp.path)
  141. .quiet()
  142. .catch(() => {})
  143. }
  144. })
  145. test("should accumulate multiple worktrees in sandboxes", async () => {
  146. const p = await loadProject()
  147. await using tmp = await tmpdir({ git: true })
  148. const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1")
  149. const worktree2 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt2")
  150. try {
  151. await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet()
  152. await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet()
  153. await p.fromDirectory(worktree1)
  154. const { project } = await p.fromDirectory(worktree2)
  155. expect(project.worktree).toBe(tmp.path)
  156. expect(project.sandboxes).toContain(worktree1)
  157. expect(project.sandboxes).toContain(worktree2)
  158. expect(project.sandboxes).not.toContain(tmp.path)
  159. } finally {
  160. await $`git worktree remove ${worktree1}`
  161. .cwd(tmp.path)
  162. .quiet()
  163. .catch(() => {})
  164. await $`git worktree remove ${worktree2}`
  165. .cwd(tmp.path)
  166. .quiet()
  167. .catch(() => {})
  168. }
  169. })
  170. })
  171. describe("Project.discover", () => {
  172. test("should discover favicon.png in root", async () => {
  173. const p = await loadProject()
  174. await using tmp = await tmpdir({ git: true })
  175. const { project } = await p.fromDirectory(tmp.path)
  176. const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
  177. await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
  178. await p.discover(project)
  179. const updated = Project.get(project.id)
  180. expect(updated).toBeDefined()
  181. expect(updated!.icon).toBeDefined()
  182. expect(updated!.icon?.url).toStartWith("data:")
  183. expect(updated!.icon?.url).toContain("base64")
  184. expect(updated!.icon?.color).toBeUndefined()
  185. })
  186. test("should not discover non-image files", async () => {
  187. const p = await loadProject()
  188. await using tmp = await tmpdir({ git: true })
  189. const { project } = await p.fromDirectory(tmp.path)
  190. await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image")
  191. await p.discover(project)
  192. const updated = Project.get(project.id)
  193. expect(updated).toBeDefined()
  194. expect(updated!.icon).toBeUndefined()
  195. })
  196. })
  197. describe("Project.update", () => {
  198. test("should update name", async () => {
  199. await using tmp = await tmpdir({ git: true })
  200. const { project } = await Project.fromDirectory(tmp.path)
  201. const updated = await Project.update({
  202. projectID: project.id,
  203. name: "New Project Name",
  204. })
  205. expect(updated.name).toBe("New Project Name")
  206. const fromDb = Project.get(project.id)
  207. expect(fromDb?.name).toBe("New Project Name")
  208. })
  209. test("should update icon url", async () => {
  210. await using tmp = await tmpdir({ git: true })
  211. const { project } = await Project.fromDirectory(tmp.path)
  212. const updated = await Project.update({
  213. projectID: project.id,
  214. icon: { url: "https://example.com/icon.png" },
  215. })
  216. expect(updated.icon?.url).toBe("https://example.com/icon.png")
  217. const fromDb = Project.get(project.id)
  218. expect(fromDb?.icon?.url).toBe("https://example.com/icon.png")
  219. })
  220. test("should update icon color", async () => {
  221. await using tmp = await tmpdir({ git: true })
  222. const { project } = await Project.fromDirectory(tmp.path)
  223. const updated = await Project.update({
  224. projectID: project.id,
  225. icon: { color: "#ff0000" },
  226. })
  227. expect(updated.icon?.color).toBe("#ff0000")
  228. const fromDb = Project.get(project.id)
  229. expect(fromDb?.icon?.color).toBe("#ff0000")
  230. })
  231. test("should update commands", async () => {
  232. await using tmp = await tmpdir({ git: true })
  233. const { project } = await Project.fromDirectory(tmp.path)
  234. const updated = await Project.update({
  235. projectID: project.id,
  236. commands: { start: "npm run dev" },
  237. })
  238. expect(updated.commands?.start).toBe("npm run dev")
  239. const fromDb = Project.get(project.id)
  240. expect(fromDb?.commands?.start).toBe("npm run dev")
  241. })
  242. test("should throw error when project not found", async () => {
  243. await using tmp = await tmpdir({ git: true })
  244. await expect(
  245. Project.update({
  246. projectID: "nonexistent-project-id",
  247. name: "Should Fail",
  248. }),
  249. ).rejects.toThrow("Project not found: nonexistent-project-id")
  250. })
  251. test("should emit GlobalBus event on update", async () => {
  252. await using tmp = await tmpdir({ git: true })
  253. const { project } = await Project.fromDirectory(tmp.path)
  254. let eventFired = false
  255. let eventPayload: any = null
  256. GlobalBus.on("event", (data) => {
  257. eventFired = true
  258. eventPayload = data
  259. })
  260. await Project.update({
  261. projectID: project.id,
  262. name: "Updated Name",
  263. })
  264. expect(eventFired).toBe(true)
  265. expect(eventPayload.payload.type).toBe("project.updated")
  266. expect(eventPayload.payload.properties.name).toBe("Updated Name")
  267. })
  268. test("should update multiple fields at once", async () => {
  269. await using tmp = await tmpdir({ git: true })
  270. const { project } = await Project.fromDirectory(tmp.path)
  271. const updated = await Project.update({
  272. projectID: project.id,
  273. name: "Multi Update",
  274. icon: { url: "https://example.com/favicon.ico", color: "#00ff00" },
  275. commands: { start: "make start" },
  276. })
  277. expect(updated.name).toBe("Multi Update")
  278. expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
  279. expect(updated.icon?.color).toBe("#00ff00")
  280. expect(updated.commands?.start).toBe("make start")
  281. })
  282. })