actions.ts 9.1 KB

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