actions.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. import { expect, type Locator, type Page } from "@playwright/test"
  2. import fs from "node:fs/promises"
  3. import os from "node:os"
  4. import path from "node:path"
  5. import { execSync } from "node:child_process"
  6. import { modKey, serverUrl } from "./utils"
  7. import {
  8. sessionItemSelector,
  9. dropdownMenuTriggerSelector,
  10. dropdownMenuContentSelector,
  11. projectMenuTriggerSelector,
  12. projectWorkspacesToggleSelector,
  13. titlebarRightSelector,
  14. popoverBodySelector,
  15. listItemSelector,
  16. listItemKeySelector,
  17. listItemKeyStartsWithSelector,
  18. workspaceItemSelector,
  19. workspaceMenuTriggerSelector,
  20. } from "./selectors"
  21. import type { createSdk } from "./utils"
  22. export async function defocus(page: Page) {
  23. await page.mouse.click(5, 5)
  24. }
  25. export async function openPalette(page: Page) {
  26. await defocus(page)
  27. await page.keyboard.press(`${modKey}+P`)
  28. const dialog = page.getByRole("dialog")
  29. await expect(dialog).toBeVisible()
  30. await expect(dialog.getByRole("textbox").first()).toBeVisible()
  31. return dialog
  32. }
  33. export async function closeDialog(page: Page, dialog: Locator) {
  34. await page.keyboard.press("Escape")
  35. const closed = await dialog
  36. .waitFor({ state: "detached", timeout: 1500 })
  37. .then(() => true)
  38. .catch(() => false)
  39. if (closed) return
  40. await page.keyboard.press("Escape")
  41. const closedSecond = await dialog
  42. .waitFor({ state: "detached", timeout: 1500 })
  43. .then(() => true)
  44. .catch(() => false)
  45. if (closedSecond) return
  46. await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
  47. await expect(dialog).toHaveCount(0)
  48. }
  49. export async function isSidebarClosed(page: Page) {
  50. const main = page.locator("main")
  51. const classes = (await main.getAttribute("class")) ?? ""
  52. return classes.includes("xl:border-l")
  53. }
  54. export async function toggleSidebar(page: Page) {
  55. await defocus(page)
  56. await page.keyboard.press(`${modKey}+B`)
  57. }
  58. export async function openSidebar(page: Page) {
  59. if (!(await isSidebarClosed(page))) return
  60. await toggleSidebar(page)
  61. await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
  62. }
  63. export async function closeSidebar(page: Page) {
  64. if (await isSidebarClosed(page)) return
  65. await toggleSidebar(page)
  66. await expect(page.locator("main")).toHaveClass(/xl:border-l/)
  67. }
  68. export async function openSettings(page: Page) {
  69. await defocus(page)
  70. const dialog = page.getByRole("dialog")
  71. await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
  72. const opened = await dialog
  73. .waitFor({ state: "visible", timeout: 3000 })
  74. .then(() => true)
  75. .catch(() => false)
  76. if (opened) return dialog
  77. await page.getByRole("button", { name: "Settings" }).first().click()
  78. await expect(dialog).toBeVisible()
  79. return dialog
  80. }
  81. export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
  82. await page.addInitScript(
  83. (args: { directory: string; serverUrl: string; extra: string[] }) => {
  84. const key = "opencode.global.dat:server"
  85. const raw = localStorage.getItem(key)
  86. const parsed = (() => {
  87. if (!raw) return undefined
  88. try {
  89. return JSON.parse(raw) as unknown
  90. } catch {
  91. return undefined
  92. }
  93. })()
  94. const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
  95. const list = Array.isArray(store.list) ? store.list : []
  96. const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
  97. const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
  98. const nextProjects = { ...(projects as Record<string, unknown>) }
  99. const add = (origin: string, directory: string) => {
  100. const current = nextProjects[origin]
  101. const items = Array.isArray(current) ? current : []
  102. const existing = items.filter(
  103. (p): p is { worktree: string; expanded?: boolean } =>
  104. !!p &&
  105. typeof p === "object" &&
  106. "worktree" in p &&
  107. typeof (p as { worktree?: unknown }).worktree === "string",
  108. )
  109. if (existing.some((p) => p.worktree === directory)) return
  110. nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
  111. }
  112. const directories = [args.directory, ...args.extra]
  113. for (const directory of directories) {
  114. add("local", directory)
  115. add(args.serverUrl, directory)
  116. }
  117. localStorage.setItem(
  118. key,
  119. JSON.stringify({
  120. list,
  121. projects: nextProjects,
  122. lastProject,
  123. }),
  124. )
  125. },
  126. { directory: input.directory, serverUrl, extra: input.extra ?? [] },
  127. )
  128. }
  129. export async function createTestProject() {
  130. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
  131. await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
  132. execSync("git init", { cwd: root, stdio: "ignore" })
  133. execSync("git add -A", { cwd: root, stdio: "ignore" })
  134. execSync('git -c user.name="e2e" -c user.email="[email protected]" commit -m "init" --allow-empty', {
  135. cwd: root,
  136. stdio: "ignore",
  137. })
  138. return root
  139. }
  140. export async function cleanupTestProject(directory: string) {
  141. await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
  142. }
  143. export function sessionIDFromUrl(url: string) {
  144. const match = /\/session\/([^/?#]+)/.exec(url)
  145. return match?.[1]
  146. }
  147. export async function hoverSessionItem(page: Page, sessionID: string) {
  148. const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
  149. await expect(sessionEl).toBeVisible()
  150. await sessionEl.hover()
  151. return sessionEl
  152. }
  153. export async function openSessionMoreMenu(page: Page, sessionID: string) {
  154. const sessionEl = await hoverSessionItem(page, sessionID)
  155. const menuTrigger = sessionEl.locator(dropdownMenuTriggerSelector).first()
  156. await expect(menuTrigger).toBeVisible()
  157. await menuTrigger.click()
  158. const menu = page.locator(dropdownMenuContentSelector).first()
  159. await expect(menu).toBeVisible()
  160. return menu
  161. }
  162. export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) {
  163. const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first()
  164. await expect(item).toBeVisible()
  165. await item.click({ force: options?.force })
  166. }
  167. export async function confirmDialog(page: Page, buttonName: string | RegExp) {
  168. const dialog = page.getByRole("dialog").first()
  169. await expect(dialog).toBeVisible()
  170. const button = dialog.getByRole("button").filter({ hasText: buttonName }).first()
  171. await expect(button).toBeVisible()
  172. await button.click()
  173. }
  174. export async function openSharePopover(page: Page) {
  175. const rightSection = page.locator(titlebarRightSelector)
  176. const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
  177. await expect(shareButton).toBeVisible()
  178. const popoverBody = page
  179. .locator(popoverBodySelector)
  180. .filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
  181. .first()
  182. const opened = await popoverBody
  183. .isVisible()
  184. .then((x) => x)
  185. .catch(() => false)
  186. if (!opened) {
  187. await shareButton.click()
  188. await expect(popoverBody).toBeVisible()
  189. }
  190. return { rightSection, popoverBody }
  191. }
  192. export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
  193. const button = page.getByRole("button").filter({ hasText: buttonName }).first()
  194. await expect(button).toBeVisible()
  195. await button.click()
  196. }
  197. export async function clickListItem(
  198. container: Locator | Page,
  199. filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string },
  200. ): Promise<Locator> {
  201. let item: Locator
  202. if (typeof filter === "string" || filter instanceof RegExp) {
  203. item = container.locator(listItemSelector).filter({ hasText: filter }).first()
  204. } else if (filter.keyStartsWith) {
  205. item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first()
  206. } else if (filter.key) {
  207. item = container.locator(listItemKeySelector(filter.key)).first()
  208. } else if (filter.text) {
  209. item = container.locator(listItemSelector).filter({ hasText: filter.text }).first()
  210. } else {
  211. throw new Error("Invalid filter provided to clickListItem")
  212. }
  213. await expect(item).toBeVisible()
  214. await item.click()
  215. return item
  216. }
  217. export async function withSession<T>(
  218. sdk: ReturnType<typeof createSdk>,
  219. title: string,
  220. callback: (session: { id: string; title: string }) => Promise<T>,
  221. ): Promise<T> {
  222. const session = await sdk.session.create({ title }).then((r) => r.data)
  223. if (!session?.id) throw new Error("Session create did not return an id")
  224. try {
  225. return await callback(session)
  226. } finally {
  227. await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
  228. }
  229. }
  230. export async function openStatusPopover(page: Page) {
  231. await defocus(page)
  232. const rightSection = page.locator(titlebarRightSelector)
  233. const trigger = rightSection.getByRole("button", { name: /status/i }).first()
  234. const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') })
  235. const opened = await popoverBody
  236. .isVisible()
  237. .then((x) => x)
  238. .catch(() => false)
  239. if (!opened) {
  240. await expect(trigger).toBeVisible()
  241. await trigger.click()
  242. await expect(popoverBody).toBeVisible()
  243. }
  244. return { rightSection, popoverBody }
  245. }
  246. export async function openProjectMenu(page: Page, projectSlug: string) {
  247. const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
  248. await expect(trigger).toHaveCount(1)
  249. await trigger.focus()
  250. await page.keyboard.press("Enter")
  251. const menu = page.locator(dropdownMenuContentSelector).first()
  252. const opened = await menu
  253. .waitFor({ state: "visible", timeout: 1500 })
  254. .then(() => true)
  255. .catch(() => false)
  256. if (opened) {
  257. const viewport = page.viewportSize()
  258. const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
  259. const y = viewport ? Math.max(viewport.height - 5, 0) : 800
  260. await page.mouse.move(x, y)
  261. return menu
  262. }
  263. await trigger.click({ force: true })
  264. await expect(menu).toBeVisible()
  265. const viewport = page.viewportSize()
  266. const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
  267. const y = viewport ? Math.max(viewport.height - 5, 0) : 800
  268. await page.mouse.move(x, y)
  269. return menu
  270. }
  271. export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
  272. const current = await page
  273. .getByRole("button", { name: "New workspace" })
  274. .first()
  275. .isVisible()
  276. .then((x) => x)
  277. .catch(() => false)
  278. if (current === enabled) return
  279. await openProjectMenu(page, projectSlug)
  280. const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
  281. await expect(toggle).toBeVisible()
  282. await toggle.click({ force: true })
  283. const expected = enabled ? "New workspace" : "New session"
  284. await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
  285. }
  286. export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
  287. const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
  288. await expect(item).toBeVisible()
  289. await item.hover()
  290. const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
  291. await expect(trigger).toBeVisible()
  292. await trigger.click({ force: true })
  293. const menu = page.locator(dropdownMenuContentSelector).first()
  294. await expect(menu).toBeVisible()
  295. return menu
  296. }