workspaces.spec.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. import { base64Decode } from "@opencode-ai/util/encode"
  2. import fs from "node:fs/promises"
  3. import path from "node:path"
  4. import type { Page } from "@playwright/test"
  5. import { test, expect } from "../fixtures"
  6. test.describe.configure({ mode: "serial" })
  7. import {
  8. cleanupTestProject,
  9. clickMenuItem,
  10. confirmDialog,
  11. openSidebar,
  12. openWorkspaceMenu,
  13. setWorkspacesEnabled,
  14. } from "../actions"
  15. import { inlineInputSelector, workspaceItemSelector } from "../selectors"
  16. function slugFromUrl(url: string) {
  17. return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
  18. }
  19. async function setupWorkspaceTest(page: Page, project: { slug: string }) {
  20. const rootSlug = project.slug
  21. await openSidebar(page)
  22. await setWorkspacesEnabled(page, rootSlug, true)
  23. await page.getByRole("button", { name: "New workspace" }).first().click()
  24. await expect
  25. .poll(
  26. () => {
  27. const slug = slugFromUrl(page.url())
  28. return slug.length > 0 && slug !== rootSlug
  29. },
  30. { timeout: 45_000 },
  31. )
  32. .toBe(true)
  33. const slug = slugFromUrl(page.url())
  34. const dir = base64Decode(slug)
  35. await openSidebar(page)
  36. await expect
  37. .poll(
  38. async () => {
  39. const item = page.locator(workspaceItemSelector(slug)).first()
  40. try {
  41. await item.hover({ timeout: 500 })
  42. return true
  43. } catch {
  44. return false
  45. }
  46. },
  47. { timeout: 60_000 },
  48. )
  49. .toBe(true)
  50. return { rootSlug, slug, directory: dir }
  51. }
  52. test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
  53. await page.setViewportSize({ width: 1400, height: 800 })
  54. await withProject(async ({ slug }) => {
  55. await openSidebar(page)
  56. await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
  57. await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
  58. await setWorkspacesEnabled(page, slug, true)
  59. await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
  60. await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
  61. await setWorkspacesEnabled(page, slug, false)
  62. await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
  63. await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
  64. })
  65. })
  66. test("can create a workspace", async ({ page, withProject }) => {
  67. await page.setViewportSize({ width: 1400, height: 800 })
  68. await withProject(async ({ slug }) => {
  69. await openSidebar(page)
  70. await setWorkspacesEnabled(page, slug, true)
  71. await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
  72. await page.getByRole("button", { name: "New workspace" }).first().click()
  73. await expect
  74. .poll(
  75. () => {
  76. const currentSlug = slugFromUrl(page.url())
  77. return currentSlug.length > 0 && currentSlug !== slug
  78. },
  79. { timeout: 45_000 },
  80. )
  81. .toBe(true)
  82. const workspaceSlug = slugFromUrl(page.url())
  83. const workspaceDir = base64Decode(workspaceSlug)
  84. await openSidebar(page)
  85. await expect
  86. .poll(
  87. async () => {
  88. const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
  89. try {
  90. await item.hover({ timeout: 500 })
  91. return true
  92. } catch {
  93. return false
  94. }
  95. },
  96. { timeout: 60_000 },
  97. )
  98. .toBe(true)
  99. await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
  100. await cleanupTestProject(workspaceDir)
  101. })
  102. })
  103. test("can rename a workspace", async ({ page, withProject }) => {
  104. await page.setViewportSize({ width: 1400, height: 800 })
  105. await withProject(async (project) => {
  106. const { slug } = await setupWorkspaceTest(page, project)
  107. const rename = `e2e workspace ${Date.now()}`
  108. const menu = await openWorkspaceMenu(page, slug)
  109. await clickMenuItem(menu, /^Rename$/i, { force: true })
  110. await expect(menu).toHaveCount(0)
  111. const item = page.locator(workspaceItemSelector(slug)).first()
  112. await expect(item).toBeVisible()
  113. const input = item.locator(inlineInputSelector).first()
  114. await expect(input).toBeVisible()
  115. await input.fill(rename)
  116. await input.press("Enter")
  117. await expect(item).toContainText(rename)
  118. })
  119. })
  120. test("can reset a workspace", async ({ page, sdk, withProject }) => {
  121. await page.setViewportSize({ width: 1400, height: 800 })
  122. await withProject(async (project) => {
  123. const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
  124. const readme = path.join(createdDir, "README.md")
  125. const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
  126. const original = await fs.readFile(readme, "utf8")
  127. const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
  128. await fs.writeFile(readme, dirty, "utf8")
  129. await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
  130. await expect
  131. .poll(async () => {
  132. return await fs
  133. .stat(extra)
  134. .then(() => true)
  135. .catch(() => false)
  136. })
  137. .toBe(true)
  138. await expect
  139. .poll(async () => {
  140. const files = await sdk.file
  141. .status({ directory: createdDir })
  142. .then((r) => r.data ?? [])
  143. .catch(() => [])
  144. return files.length
  145. })
  146. .toBeGreaterThan(0)
  147. const menu = await openWorkspaceMenu(page, slug)
  148. await clickMenuItem(menu, /^Reset$/i, { force: true })
  149. await confirmDialog(page, /^Reset workspace$/i)
  150. await expect
  151. .poll(
  152. async () => {
  153. const files = await sdk.file
  154. .status({ directory: createdDir })
  155. .then((r) => r.data ?? [])
  156. .catch(() => [])
  157. return files.length
  158. },
  159. { timeout: 60_000 },
  160. )
  161. .toBe(0)
  162. await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
  163. await expect
  164. .poll(async () => {
  165. return await fs
  166. .stat(extra)
  167. .then(() => true)
  168. .catch(() => false)
  169. })
  170. .toBe(false)
  171. })
  172. })
  173. test("can delete a workspace", async ({ page, withProject }) => {
  174. await page.setViewportSize({ width: 1400, height: 800 })
  175. await withProject(async (project) => {
  176. const { rootSlug, slug } = await setupWorkspaceTest(page, project)
  177. const menu = await openWorkspaceMenu(page, slug)
  178. await clickMenuItem(menu, /^Delete$/i, { force: true })
  179. await confirmDialog(page, /^Delete workspace$/i)
  180. await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
  181. await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
  182. await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
  183. })
  184. })
  185. test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
  186. await page.setViewportSize({ width: 1400, height: 800 })
  187. await withProject(async ({ slug: rootSlug }) => {
  188. const workspaces = [] as { directory: string; slug: string }[]
  189. const listSlugs = async () => {
  190. const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
  191. const slugs = await nodes.evaluateAll((els) => {
  192. return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
  193. })
  194. return slugs
  195. }
  196. const waitReady = async (slug: string) => {
  197. await expect
  198. .poll(
  199. async () => {
  200. const item = page.locator(workspaceItemSelector(slug)).first()
  201. try {
  202. await item.hover({ timeout: 500 })
  203. return true
  204. } catch {
  205. return false
  206. }
  207. },
  208. { timeout: 60_000 },
  209. )
  210. .toBe(true)
  211. }
  212. const drag = async (from: string, to: string) => {
  213. const src = page.locator(workspaceItemSelector(from)).first()
  214. const dst = page.locator(workspaceItemSelector(to)).first()
  215. await src.scrollIntoViewIfNeeded()
  216. await dst.scrollIntoViewIfNeeded()
  217. const a = await src.boundingBox()
  218. const b = await dst.boundingBox()
  219. if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
  220. await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
  221. await page.mouse.down()
  222. await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
  223. await page.mouse.up()
  224. }
  225. try {
  226. await openSidebar(page)
  227. await setWorkspacesEnabled(page, rootSlug, true)
  228. for (const _ of [0, 1]) {
  229. const prev = slugFromUrl(page.url())
  230. await page.getByRole("button", { name: "New workspace" }).first().click()
  231. await expect
  232. .poll(
  233. () => {
  234. const slug = slugFromUrl(page.url())
  235. return slug.length > 0 && slug !== rootSlug && slug !== prev
  236. },
  237. { timeout: 45_000 },
  238. )
  239. .toBe(true)
  240. const slug = slugFromUrl(page.url())
  241. const dir = base64Decode(slug)
  242. workspaces.push({ slug, directory: dir })
  243. await openSidebar(page)
  244. }
  245. if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
  246. const a = workspaces[0].slug
  247. const b = workspaces[1].slug
  248. await waitReady(a)
  249. await waitReady(b)
  250. const list = async () => {
  251. const slugs = await listSlugs()
  252. return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
  253. }
  254. await expect
  255. .poll(async () => {
  256. const slugs = await list()
  257. return slugs.length === 2
  258. })
  259. .toBe(true)
  260. const before = await list()
  261. const from = before[1]
  262. const to = before[0]
  263. if (!from || !to) throw new Error("Failed to resolve initial workspace order")
  264. await drag(from, to)
  265. await expect.poll(async () => await list()).toEqual([from, to])
  266. } finally {
  267. await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
  268. }
  269. })
  270. })