actions.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  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
  24. .evaluate(() => {
  25. const el = document.activeElement
  26. if (el instanceof HTMLElement) el.blur()
  27. })
  28. .catch(() => undefined)
  29. }
  30. export async function openPalette(page: Page) {
  31. await defocus(page)
  32. await page.keyboard.press(`${modKey}+P`)
  33. const dialog = page.getByRole("dialog")
  34. await expect(dialog).toBeVisible()
  35. await expect(dialog.getByRole("textbox").first()).toBeVisible()
  36. return dialog
  37. }
  38. export async function closeDialog(page: Page, dialog: Locator) {
  39. await page.keyboard.press("Escape")
  40. const closed = await dialog
  41. .waitFor({ state: "detached", timeout: 1500 })
  42. .then(() => true)
  43. .catch(() => false)
  44. if (closed) return
  45. await page.keyboard.press("Escape")
  46. const closedSecond = await dialog
  47. .waitFor({ state: "detached", timeout: 1500 })
  48. .then(() => true)
  49. .catch(() => false)
  50. if (closedSecond) return
  51. await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
  52. await expect(dialog).toHaveCount(0)
  53. }
  54. export async function isSidebarClosed(page: Page) {
  55. const main = page.locator("main")
  56. const classes = (await main.getAttribute("class")) ?? ""
  57. return classes.includes("xl:border-l")
  58. }
  59. export async function toggleSidebar(page: Page) {
  60. await defocus(page)
  61. await page.keyboard.press(`${modKey}+B`)
  62. }
  63. export async function openSidebar(page: Page) {
  64. if (!(await isSidebarClosed(page))) return
  65. const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
  66. const visible = await button
  67. .isVisible()
  68. .then((x) => x)
  69. .catch(() => false)
  70. if (visible) await button.click()
  71. if (!visible) await toggleSidebar(page)
  72. const main = page.locator("main")
  73. const opened = await expect(main)
  74. .not.toHaveClass(/xl:border-l/, { timeout: 1500 })
  75. .then(() => true)
  76. .catch(() => false)
  77. if (opened) return
  78. await toggleSidebar(page)
  79. await expect(main).not.toHaveClass(/xl:border-l/)
  80. }
  81. export async function closeSidebar(page: Page) {
  82. if (await isSidebarClosed(page)) return
  83. const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
  84. const visible = await button
  85. .isVisible()
  86. .then((x) => x)
  87. .catch(() => false)
  88. if (visible) await button.click()
  89. if (!visible) await toggleSidebar(page)
  90. const main = page.locator("main")
  91. const closed = await expect(main)
  92. .toHaveClass(/xl:border-l/, { timeout: 1500 })
  93. .then(() => true)
  94. .catch(() => false)
  95. if (closed) return
  96. await toggleSidebar(page)
  97. await expect(main).toHaveClass(/xl:border-l/)
  98. }
  99. export async function openSettings(page: Page) {
  100. await defocus(page)
  101. const dialog = page.getByRole("dialog")
  102. await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
  103. const opened = await dialog
  104. .waitFor({ state: "visible", timeout: 3000 })
  105. .then(() => true)
  106. .catch(() => false)
  107. if (opened) return dialog
  108. await page.getByRole("button", { name: "Settings" }).first().click()
  109. await expect(dialog).toBeVisible()
  110. return dialog
  111. }
  112. export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
  113. await page.addInitScript(
  114. (args: { directory: string; serverUrl: string; extra: string[] }) => {
  115. const key = "opencode.global.dat:server"
  116. const raw = localStorage.getItem(key)
  117. const parsed = (() => {
  118. if (!raw) return undefined
  119. try {
  120. return JSON.parse(raw) as unknown
  121. } catch {
  122. return undefined
  123. }
  124. })()
  125. const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
  126. const list = Array.isArray(store.list) ? store.list : []
  127. const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
  128. const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
  129. const nextProjects = { ...(projects as Record<string, unknown>) }
  130. const add = (origin: string, directory: string) => {
  131. const current = nextProjects[origin]
  132. const items = Array.isArray(current) ? current : []
  133. const existing = items.filter(
  134. (p): p is { worktree: string; expanded?: boolean } =>
  135. !!p &&
  136. typeof p === "object" &&
  137. "worktree" in p &&
  138. typeof (p as { worktree?: unknown }).worktree === "string",
  139. )
  140. if (existing.some((p) => p.worktree === directory)) return
  141. nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
  142. }
  143. const directories = [args.directory, ...args.extra]
  144. for (const directory of directories) {
  145. add("local", directory)
  146. add(args.serverUrl, directory)
  147. }
  148. localStorage.setItem(
  149. key,
  150. JSON.stringify({
  151. list,
  152. projects: nextProjects,
  153. lastProject,
  154. }),
  155. )
  156. },
  157. { directory: input.directory, serverUrl, extra: input.extra ?? [] },
  158. )
  159. }
  160. export async function createTestProject() {
  161. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
  162. await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
  163. execSync("git init", { cwd: root, stdio: "ignore" })
  164. execSync("git add -A", { cwd: root, stdio: "ignore" })
  165. execSync('git -c user.name="e2e" -c user.email="[email protected]" commit -m "init" --allow-empty', {
  166. cwd: root,
  167. stdio: "ignore",
  168. })
  169. return root
  170. }
  171. export async function cleanupTestProject(directory: string) {
  172. await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
  173. }
  174. export function sessionIDFromUrl(url: string) {
  175. const match = /\/session\/([^/?#]+)/.exec(url)
  176. return match?.[1]
  177. }
  178. export async function hoverSessionItem(page: Page, sessionID: string) {
  179. const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
  180. await expect(sessionEl).toBeVisible()
  181. await sessionEl.hover()
  182. return sessionEl
  183. }
  184. export async function openSessionMoreMenu(page: Page, sessionID: string) {
  185. await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
  186. const scroller = page.locator(".scroll-view__viewport").first()
  187. await expect(scroller).toBeVisible()
  188. await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
  189. const menu = page
  190. .locator(dropdownMenuContentSelector)
  191. .filter({ has: page.getByRole("menuitem", { name: /rename/i }) })
  192. .filter({ has: page.getByRole("menuitem", { name: /archive/i }) })
  193. .filter({ has: page.getByRole("menuitem", { name: /delete/i }) })
  194. .first()
  195. const opened = await menu
  196. .isVisible()
  197. .then((x) => x)
  198. .catch(() => false)
  199. if (opened) return menu
  200. const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
  201. await expect(menuTrigger).toBeVisible()
  202. await menuTrigger.click()
  203. await expect(menu).toBeVisible()
  204. return menu
  205. }
  206. export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) {
  207. const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first()
  208. await expect(item).toBeVisible()
  209. await item.click({ force: options?.force })
  210. }
  211. export async function confirmDialog(page: Page, buttonName: string | RegExp) {
  212. const dialog = page.getByRole("dialog").first()
  213. await expect(dialog).toBeVisible()
  214. const button = dialog.getByRole("button").filter({ hasText: buttonName }).first()
  215. await expect(button).toBeVisible()
  216. await button.click()
  217. }
  218. export async function openSharePopover(page: Page) {
  219. const rightSection = page.locator(titlebarRightSelector)
  220. const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
  221. await expect(shareButton).toBeVisible()
  222. const popoverBody = page
  223. .locator(popoverBodySelector)
  224. .filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
  225. .first()
  226. const opened = await popoverBody
  227. .isVisible()
  228. .then((x) => x)
  229. .catch(() => false)
  230. if (!opened) {
  231. await shareButton.click()
  232. await expect(popoverBody).toBeVisible()
  233. }
  234. return { rightSection, popoverBody }
  235. }
  236. export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
  237. const button = page.getByRole("button").filter({ hasText: buttonName }).first()
  238. await expect(button).toBeVisible()
  239. await button.click()
  240. }
  241. export async function clickListItem(
  242. container: Locator | Page,
  243. filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string },
  244. ): Promise<Locator> {
  245. let item: Locator
  246. if (typeof filter === "string" || filter instanceof RegExp) {
  247. item = container.locator(listItemSelector).filter({ hasText: filter }).first()
  248. } else if (filter.keyStartsWith) {
  249. item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first()
  250. } else if (filter.key) {
  251. item = container.locator(listItemKeySelector(filter.key)).first()
  252. } else if (filter.text) {
  253. item = container.locator(listItemSelector).filter({ hasText: filter.text }).first()
  254. } else {
  255. throw new Error("Invalid filter provided to clickListItem")
  256. }
  257. await expect(item).toBeVisible()
  258. await item.click()
  259. return item
  260. }
  261. export async function withSession<T>(
  262. sdk: ReturnType<typeof createSdk>,
  263. title: string,
  264. callback: (session: { id: string; title: string }) => Promise<T>,
  265. ): Promise<T> {
  266. const session = await sdk.session.create({ title }).then((r) => r.data)
  267. if (!session?.id) throw new Error("Session create did not return an id")
  268. try {
  269. return await callback(session)
  270. } finally {
  271. await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
  272. }
  273. }
  274. const seedSystem = [
  275. "You are seeding deterministic e2e UI state.",
  276. "Follow the user's instruction exactly.",
  277. "When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
  278. "Do not call any extra tools.",
  279. ].join(" ")
  280. const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
  281. const timeout = input.timeout ?? 30_000
  282. const end = Date.now() + timeout
  283. while (Date.now() < end) {
  284. const value = await input.probe()
  285. if (value !== undefined) return value
  286. await new Promise((resolve) => setTimeout(resolve, 250))
  287. }
  288. }
  289. const seed = async <T>(input: {
  290. sessionID: string
  291. prompt: string
  292. sdk: ReturnType<typeof createSdk>
  293. probe: () => Promise<T | undefined>
  294. timeout?: number
  295. attempts?: number
  296. }) => {
  297. for (let i = 0; i < (input.attempts ?? 2); i++) {
  298. await input.sdk.session.promptAsync({
  299. sessionID: input.sessionID,
  300. agent: "build",
  301. system: seedSystem,
  302. parts: [{ type: "text", text: input.prompt }],
  303. })
  304. const value = await wait({ probe: input.probe, timeout: input.timeout })
  305. if (value !== undefined) return value
  306. }
  307. }
  308. export async function seedSessionQuestion(
  309. sdk: ReturnType<typeof createSdk>,
  310. input: {
  311. sessionID: string
  312. questions: Array<{
  313. header: string
  314. question: string
  315. options: Array<{ label: string; description: string }>
  316. multiple?: boolean
  317. custom?: boolean
  318. }>
  319. },
  320. ) {
  321. const first = input.questions[0]
  322. if (!first) throw new Error("Question seed requires at least one question")
  323. const text = [
  324. "Your only valid response is one question tool call.",
  325. `Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
  326. "Do not output plain text.",
  327. "After calling the tool, wait for the user response.",
  328. ].join("\n")
  329. const result = await seed({
  330. sdk,
  331. sessionID: input.sessionID,
  332. prompt: text,
  333. timeout: 30_000,
  334. probe: async () => {
  335. const list = await sdk.question.list().then((x) => x.data ?? [])
  336. return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
  337. },
  338. })
  339. if (!result) throw new Error("Timed out seeding question request")
  340. return { id: result.id }
  341. }
  342. export async function seedSessionPermission(
  343. sdk: ReturnType<typeof createSdk>,
  344. input: {
  345. sessionID: string
  346. permission: string
  347. patterns: string[]
  348. description?: string
  349. },
  350. ) {
  351. const text = [
  352. "Your only valid response is one bash tool call.",
  353. `Use this JSON input: ${JSON.stringify({
  354. command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
  355. workdir: "/",
  356. description: input.description ?? `seed ${input.permission} permission request`,
  357. })}`,
  358. "Do not output plain text.",
  359. ].join("\n")
  360. const result = await seed({
  361. sdk,
  362. sessionID: input.sessionID,
  363. prompt: text,
  364. timeout: 30_000,
  365. probe: async () => {
  366. const list = await sdk.permission.list().then((x) => x.data ?? [])
  367. return list.find((item) => item.sessionID === input.sessionID)
  368. },
  369. })
  370. if (!result) throw new Error("Timed out seeding permission request")
  371. return { id: result.id }
  372. }
  373. export async function seedSessionTodos(
  374. sdk: ReturnType<typeof createSdk>,
  375. input: {
  376. sessionID: string
  377. todos: Array<{ content: string; status: string; priority: string }>
  378. },
  379. ) {
  380. const text = [
  381. "Your only valid response is one todowrite tool call.",
  382. `Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
  383. "Do not output plain text.",
  384. ].join("\n")
  385. const target = JSON.stringify(input.todos)
  386. const result = await seed({
  387. sdk,
  388. sessionID: input.sessionID,
  389. prompt: text,
  390. timeout: 30_000,
  391. probe: async () => {
  392. const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
  393. if (JSON.stringify(todos) !== target) return
  394. return true
  395. },
  396. })
  397. if (!result) throw new Error("Timed out seeding todos")
  398. return true
  399. }
  400. export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
  401. const [questions, permissions] = await Promise.all([
  402. sdk.question.list().then((x) => x.data ?? []),
  403. sdk.permission.list().then((x) => x.data ?? []),
  404. ])
  405. await Promise.all([
  406. ...questions
  407. .filter((item) => item.sessionID === sessionID)
  408. .map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
  409. ...permissions
  410. .filter((item) => item.sessionID === sessionID)
  411. .map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
  412. ])
  413. return true
  414. }
  415. export async function openStatusPopover(page: Page) {
  416. await defocus(page)
  417. const rightSection = page.locator(titlebarRightSelector)
  418. const trigger = rightSection.getByRole("button", { name: /status/i }).first()
  419. const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') })
  420. const opened = await popoverBody
  421. .isVisible()
  422. .then((x) => x)
  423. .catch(() => false)
  424. if (!opened) {
  425. await expect(trigger).toBeVisible()
  426. await trigger.click()
  427. await expect(popoverBody).toBeVisible()
  428. }
  429. return { rightSection, popoverBody }
  430. }
  431. export async function openProjectMenu(page: Page, projectSlug: string) {
  432. const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
  433. await expect(trigger).toHaveCount(1)
  434. await trigger.focus()
  435. await page.keyboard.press("Enter")
  436. const menu = page.locator(dropdownMenuContentSelector).first()
  437. const opened = await menu
  438. .waitFor({ state: "visible", timeout: 1500 })
  439. .then(() => true)
  440. .catch(() => false)
  441. if (opened) {
  442. const viewport = page.viewportSize()
  443. const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
  444. const y = viewport ? Math.max(viewport.height - 5, 0) : 800
  445. await page.mouse.move(x, y)
  446. return menu
  447. }
  448. await trigger.click({ force: true })
  449. await expect(menu).toBeVisible()
  450. const viewport = page.viewportSize()
  451. const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
  452. const y = viewport ? Math.max(viewport.height - 5, 0) : 800
  453. await page.mouse.move(x, y)
  454. return menu
  455. }
  456. export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
  457. const current = await page
  458. .getByRole("button", { name: "New workspace" })
  459. .first()
  460. .isVisible()
  461. .then((x) => x)
  462. .catch(() => false)
  463. if (current === enabled) return
  464. await openProjectMenu(page, projectSlug)
  465. const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
  466. await expect(toggle).toBeVisible()
  467. await toggle.click({ force: true })
  468. const expected = enabled ? "New workspace" : "New session"
  469. await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
  470. }
  471. export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
  472. const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
  473. await expect(item).toBeVisible()
  474. await item.hover()
  475. const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
  476. await expect(trigger).toBeVisible()
  477. await trigger.click({ force: true })
  478. const menu = page.locator(dropdownMenuContentSelector).first()
  479. await expect(menu).toBeVisible()
  480. return menu
  481. }