project.test.ts 12 KB

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