actions.ts 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018
  1. import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
  2. import { expect, type Locator, type Page } from "@playwright/test"
  3. import fs from "node:fs/promises"
  4. import os from "node:os"
  5. import path from "node:path"
  6. import { execSync } from "node:child_process"
  7. import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
  8. import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
  9. import {
  10. dropdownMenuTriggerSelector,
  11. dropdownMenuContentSelector,
  12. projectSwitchSelector,
  13. projectMenuTriggerSelector,
  14. projectCloseMenuSelector,
  15. projectWorkspacesToggleSelector,
  16. titlebarRightSelector,
  17. popoverBodySelector,
  18. listItemSelector,
  19. listItemKeySelector,
  20. listItemKeyStartsWithSelector,
  21. promptSelector,
  22. terminalSelector,
  23. workspaceItemSelector,
  24. workspaceMenuTriggerSelector,
  25. } from "./selectors"
  26. const phase = new WeakMap<Page, "test" | "cleanup">()
  27. export function setHealthPhase(page: Page, value: "test" | "cleanup") {
  28. phase.set(page, value)
  29. }
  30. export function healthPhase(page: Page) {
  31. return phase.get(page) ?? "test"
  32. }
  33. export async function defocus(page: Page) {
  34. await page
  35. .evaluate(() => {
  36. const el = document.activeElement
  37. if (el instanceof HTMLElement) el.blur()
  38. })
  39. .catch(() => undefined)
  40. }
  41. async function terminalID(term: Locator) {
  42. const id = await term.getAttribute(terminalAttr)
  43. if (id) return id
  44. throw new Error(`Active terminal missing ${terminalAttr}`)
  45. }
  46. export async function terminalConnects(page: Page, input?: { term?: Locator }) {
  47. const term = input?.term ?? page.locator(terminalSelector).first()
  48. const id = await terminalID(term)
  49. return page.evaluate((id) => {
  50. return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0
  51. }, id)
  52. }
  53. export async function disconnectTerminal(page: Page, input?: { term?: Locator }) {
  54. const term = input?.term ?? page.locator(terminalSelector).first()
  55. const id = await terminalID(term)
  56. await page.evaluate((id) => {
  57. ;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.()
  58. }, id)
  59. }
  60. async function terminalReady(page: Page, term?: Locator) {
  61. const next = term ?? page.locator(terminalSelector).first()
  62. const id = await terminalID(next)
  63. return page.evaluate((id) => {
  64. const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
  65. return !!state?.connected && (state.settled ?? 0) > 0
  66. }, id)
  67. }
  68. async function terminalFocusIdle(page: Page, term?: Locator) {
  69. const next = term ?? page.locator(terminalSelector).first()
  70. const id = await terminalID(next)
  71. return page.evaluate((id) => {
  72. const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
  73. return (state?.focusing ?? 0) === 0
  74. }, id)
  75. }
  76. async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
  77. const next = input.term ?? page.locator(terminalSelector).first()
  78. const id = await terminalID(next)
  79. return page.evaluate(
  80. (input) => {
  81. const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
  82. return state?.rendered.includes(input.token) ?? false
  83. },
  84. { id, token: input.token },
  85. )
  86. }
  87. async function promptSlashActive(page: Page, id: string) {
  88. return page.evaluate((id) => {
  89. const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
  90. if (state?.popover !== "slash") return false
  91. if (!state.slash.ids.includes(id)) return false
  92. return state.slash.active === id
  93. }, id)
  94. }
  95. async function promptSlashSelects(page: Page) {
  96. return page.evaluate(() => {
  97. return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0
  98. })
  99. }
  100. async function promptSlashSelected(page: Page, input: { id: string; count: number }) {
  101. return page.evaluate((input) => {
  102. const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
  103. if (!state) return false
  104. return state.selected === input.id && state.selects >= input.count
  105. }, input)
  106. }
  107. export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
  108. const term = input?.term ?? page.locator(terminalSelector).first()
  109. const timeout = input?.timeout ?? 10_000
  110. await expect(term).toBeVisible()
  111. await expect(term.locator("textarea")).toHaveCount(1)
  112. await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
  113. }
  114. export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) {
  115. const term = input?.term ?? page.locator(terminalSelector).first()
  116. const timeout = input?.timeout ?? 10_000
  117. await waitTerminalReady(page, { term, timeout })
  118. await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true)
  119. }
  120. export async function showPromptSlash(
  121. page: Page,
  122. input: { id: string; text: string; prompt?: Locator; timeout?: number },
  123. ) {
  124. const prompt = input.prompt ?? page.locator(promptSelector)
  125. const timeout = input.timeout ?? 10_000
  126. await expect
  127. .poll(
  128. async () => {
  129. await prompt.click().catch(() => false)
  130. await prompt.fill(input.text).catch(() => false)
  131. return promptSlashActive(page, input.id).catch(() => false)
  132. },
  133. { timeout },
  134. )
  135. .toBe(true)
  136. }
  137. export async function runPromptSlash(
  138. page: Page,
  139. input: { id: string; text: string; prompt?: Locator; timeout?: number },
  140. ) {
  141. const prompt = input.prompt ?? page.locator(promptSelector)
  142. const timeout = input.timeout ?? 10_000
  143. const count = await promptSlashSelects(page)
  144. await showPromptSlash(page, input)
  145. await prompt.press("Enter")
  146. await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true)
  147. }
  148. export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
  149. const term = input.term ?? page.locator(terminalSelector).first()
  150. const timeout = input.timeout ?? 10_000
  151. await waitTerminalReady(page, { term, timeout })
  152. const textarea = term.locator("textarea")
  153. await term.click()
  154. await expect(textarea).toBeFocused()
  155. await page.keyboard.type(input.cmd)
  156. await page.keyboard.press("Enter")
  157. await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
  158. }
  159. export async function openPalette(page: Page, key = "K") {
  160. await defocus(page)
  161. await page.keyboard.press(`${modKey}+${key}`)
  162. const dialog = page.getByRole("dialog")
  163. await expect(dialog).toBeVisible()
  164. await expect(dialog.getByRole("textbox").first()).toBeVisible()
  165. return dialog
  166. }
  167. export async function closeDialog(page: Page, dialog: Locator) {
  168. await page.keyboard.press("Escape")
  169. const closed = await dialog
  170. .waitFor({ state: "detached", timeout: 1500 })
  171. .then(() => true)
  172. .catch(() => false)
  173. if (closed) return
  174. await page.keyboard.press("Escape")
  175. const closedSecond = await dialog
  176. .waitFor({ state: "detached", timeout: 1500 })
  177. .then(() => true)
  178. .catch(() => false)
  179. if (closedSecond) return
  180. await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
  181. await expect(dialog).toHaveCount(0)
  182. }
  183. export async function isSidebarClosed(page: Page) {
  184. const button = await waitSidebarButton(page, "isSidebarClosed")
  185. return (await button.getAttribute("aria-expanded")) !== "true"
  186. }
  187. async function errorBoundaryText(page: Page) {
  188. const title = page.getByRole("heading", { name: /something went wrong/i }).first()
  189. if (!(await title.isVisible().catch(() => false))) return
  190. const description = await page
  191. .getByText(/an error occurred while loading the application\./i)
  192. .first()
  193. .textContent()
  194. .catch(() => "")
  195. const detail = await page
  196. .getByRole("textbox", { name: /error details/i })
  197. .first()
  198. .inputValue()
  199. .catch(async () =>
  200. (
  201. (await page
  202. .getByRole("textbox", { name: /error details/i })
  203. .first()
  204. .textContent()
  205. .catch(() => "")) ?? ""
  206. ).trim(),
  207. )
  208. return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
  209. }
  210. export async function assertHealthy(page: Page, context: string) {
  211. const text = await errorBoundaryText(page)
  212. if (!text) return
  213. console.log(`[e2e:error-boundary][${context}]\n${text}`)
  214. throw new Error(`Error boundary during ${context}\n${text}`)
  215. }
  216. async function waitSidebarButton(page: Page, context: string) {
  217. const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
  218. const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
  219. await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
  220. await assertHealthy(page, context)
  221. return button
  222. }
  223. export async function toggleSidebar(page: Page) {
  224. await defocus(page)
  225. await page.keyboard.press(`${modKey}+B`)
  226. }
  227. export async function openSidebar(page: Page) {
  228. if (!(await isSidebarClosed(page))) return
  229. const button = await waitSidebarButton(page, "openSidebar")
  230. await button.click()
  231. const opened = await expect(button)
  232. .toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
  233. .then(() => true)
  234. .catch(() => false)
  235. if (opened) return
  236. await toggleSidebar(page)
  237. await expect(button).toHaveAttribute("aria-expanded", "true")
  238. }
  239. export async function closeSidebar(page: Page) {
  240. if (await isSidebarClosed(page)) return
  241. const button = await waitSidebarButton(page, "closeSidebar")
  242. await button.click()
  243. const closed = await expect(button)
  244. .toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
  245. .then(() => true)
  246. .catch(() => false)
  247. if (closed) return
  248. await toggleSidebar(page)
  249. await expect(button).toHaveAttribute("aria-expanded", "false")
  250. }
  251. export async function openSettings(page: Page) {
  252. await assertHealthy(page, "openSettings")
  253. await defocus(page)
  254. const dialog = page.getByRole("dialog")
  255. await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
  256. const opened = await dialog
  257. .waitFor({ state: "visible", timeout: 3000 })
  258. .then(() => true)
  259. .catch(() => false)
  260. if (opened) return dialog
  261. await assertHealthy(page, "openSettings")
  262. await page.getByRole("button", { name: "Settings" }).first().click()
  263. await expect(dialog).toBeVisible()
  264. return dialog
  265. }
  266. export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
  267. await page.addInitScript(
  268. (args: { directory: string; serverUrl: string; extra: string[] }) => {
  269. const key = "opencode.global.dat:server"
  270. const raw = localStorage.getItem(key)
  271. const parsed = (() => {
  272. if (!raw) return undefined
  273. try {
  274. return JSON.parse(raw) as unknown
  275. } catch {
  276. return undefined
  277. }
  278. })()
  279. const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
  280. const list = Array.isArray(store.list) ? store.list : []
  281. const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
  282. const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
  283. const nextProjects = { ...(projects as Record<string, unknown>) }
  284. const add = (origin: string, directory: string) => {
  285. const current = nextProjects[origin]
  286. const items = Array.isArray(current) ? current : []
  287. const existing = items.filter(
  288. (p): p is { worktree: string; expanded?: boolean } =>
  289. !!p &&
  290. typeof p === "object" &&
  291. "worktree" in p &&
  292. typeof (p as { worktree?: unknown }).worktree === "string",
  293. )
  294. if (existing.some((p) => p.worktree === directory)) return
  295. nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
  296. }
  297. const directories = [args.directory, ...args.extra]
  298. for (const directory of directories) {
  299. add("local", directory)
  300. add(args.serverUrl, directory)
  301. }
  302. localStorage.setItem(
  303. key,
  304. JSON.stringify({
  305. list,
  306. projects: nextProjects,
  307. lastProject,
  308. }),
  309. )
  310. },
  311. { directory: input.directory, serverUrl, extra: input.extra ?? [] },
  312. )
  313. }
  314. export async function createTestProject() {
  315. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
  316. const id = `e2e-${path.basename(root)}`
  317. await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
  318. execSync("git init", { cwd: root, stdio: "ignore" })
  319. await fs.writeFile(path.join(root, ".git", "opencode"), id)
  320. execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
  321. execSync("git add -A", { cwd: root, stdio: "ignore" })
  322. execSync('git -c user.name="e2e" -c user.email="[email protected]" commit -m "init" --allow-empty', {
  323. cwd: root,
  324. stdio: "ignore",
  325. })
  326. return resolveDirectory(root)
  327. }
  328. export async function cleanupTestProject(directory: string) {
  329. try {
  330. execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
  331. } catch {}
  332. await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
  333. }
  334. export function slugFromUrl(url: string) {
  335. return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
  336. }
  337. async function probeSession(page: Page) {
  338. return page
  339. .evaluate(() => {
  340. const win = window as E2EWindow
  341. const current = win.__opencode_e2e?.model?.current
  342. if (!current) return null
  343. return { dir: current.dir, sessionID: current.sessionID }
  344. })
  345. .catch(() => null as { dir?: string; sessionID?: string } | null)
  346. }
  347. export async function waitSlug(page: Page, skip: string[] = []) {
  348. let prev = ""
  349. let next = ""
  350. await expect
  351. .poll(
  352. async () => {
  353. await assertHealthy(page, "waitSlug")
  354. const slug = slugFromUrl(page.url())
  355. if (!slug) return ""
  356. if (skip.includes(slug)) return ""
  357. if (slug !== prev) {
  358. prev = slug
  359. next = ""
  360. return ""
  361. }
  362. next = slug
  363. return slug
  364. },
  365. { timeout: 45_000 },
  366. )
  367. .not.toBe("")
  368. return next
  369. }
  370. export async function resolveSlug(slug: string) {
  371. const directory = base64Decode(slug)
  372. if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
  373. const resolved = await resolveDirectory(directory)
  374. return { directory: resolved, slug: base64Encode(resolved), raw: slug }
  375. }
  376. export async function waitDir(page: Page, directory: string) {
  377. const target = await resolveDirectory(directory)
  378. await expect
  379. .poll(
  380. async () => {
  381. await assertHealthy(page, "waitDir")
  382. const slug = slugFromUrl(page.url())
  383. if (!slug) return ""
  384. return resolveSlug(slug)
  385. .then((item) => item.directory)
  386. .catch(() => "")
  387. },
  388. { timeout: 45_000 },
  389. )
  390. .toBe(target)
  391. return { directory: target, slug: base64Encode(target) }
  392. }
  393. export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
  394. const target = await resolveDirectory(input.directory)
  395. await expect
  396. .poll(
  397. async () => {
  398. await assertHealthy(page, "waitSession")
  399. const slug = slugFromUrl(page.url())
  400. if (!slug) return false
  401. const resolved = await resolveSlug(slug).catch(() => undefined)
  402. if (!resolved || resolved.directory !== target) return false
  403. if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
  404. const state = await probeSession(page)
  405. if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
  406. if (state?.dir) {
  407. const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
  408. if (dir !== target) return false
  409. }
  410. return page
  411. .locator(promptSelector)
  412. .first()
  413. .isVisible()
  414. .catch(() => false)
  415. },
  416. { timeout: 45_000 },
  417. )
  418. .toBe(true)
  419. return { directory: target, slug: base64Encode(target) }
  420. }
  421. export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
  422. const sdk = createSdk(directory)
  423. const target = await resolveDirectory(directory)
  424. await expect
  425. .poll(
  426. async () => {
  427. const data = await sdk.session
  428. .get({ sessionID })
  429. .then((x) => x.data)
  430. .catch(() => undefined)
  431. if (!data?.directory) return ""
  432. return resolveDirectory(data.directory).catch(() => data.directory)
  433. },
  434. { timeout },
  435. )
  436. .toBe(target)
  437. await expect
  438. .poll(
  439. async () => {
  440. const items = await sdk.session
  441. .messages({ sessionID, limit: 20 })
  442. .then((x) => x.data ?? [])
  443. .catch(() => [])
  444. return items.some((item) => item.info.role === "user")
  445. },
  446. { timeout },
  447. )
  448. .toBe(true)
  449. }
  450. export function sessionIDFromUrl(url: string) {
  451. const match = /\/session\/([^/?#]+)/.exec(url)
  452. return match?.[1]
  453. }
  454. export async function hoverSessionItem(page: Page, sessionID: string) {
  455. const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
  456. await expect(sessionEl).toBeVisible()
  457. await sessionEl.hover()
  458. return sessionEl
  459. }
  460. export async function openSessionMoreMenu(page: Page, sessionID: string) {
  461. await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
  462. const scroller = page.locator(".scroll-view__viewport").first()
  463. await expect(scroller).toBeVisible()
  464. await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
  465. const menu = page
  466. .locator(dropdownMenuContentSelector)
  467. .filter({ has: page.getByRole("menuitem", { name: /rename/i }) })
  468. .filter({ has: page.getByRole("menuitem", { name: /archive/i }) })
  469. .filter({ has: page.getByRole("menuitem", { name: /delete/i }) })
  470. .first()
  471. const opened = await menu
  472. .isVisible()
  473. .then((x) => x)
  474. .catch(() => false)
  475. if (opened) return menu
  476. const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
  477. await expect(menuTrigger).toBeVisible()
  478. await menuTrigger.click()
  479. await expect(menu).toBeVisible()
  480. return menu
  481. }
  482. export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) {
  483. const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first()
  484. await expect(item).toBeVisible()
  485. await item.click({ force: options?.force })
  486. }
  487. export async function confirmDialog(page: Page, buttonName: string | RegExp) {
  488. const dialog = page.getByRole("dialog").first()
  489. await expect(dialog).toBeVisible()
  490. const button = dialog.getByRole("button").filter({ hasText: buttonName }).first()
  491. await expect(button).toBeVisible()
  492. await button.click()
  493. }
  494. export async function openSharePopover(page: Page) {
  495. const rightSection = page.locator(titlebarRightSelector)
  496. const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
  497. await expect(shareButton).toBeVisible()
  498. const popoverBody = page
  499. .locator(popoverBodySelector)
  500. .filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
  501. .first()
  502. const opened = await popoverBody
  503. .isVisible()
  504. .then((x) => x)
  505. .catch(() => false)
  506. if (!opened) {
  507. await shareButton.click()
  508. await expect(popoverBody).toBeVisible()
  509. }
  510. return { rightSection, popoverBody }
  511. }
  512. export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
  513. const button = page.getByRole("button").filter({ hasText: buttonName }).first()
  514. await expect(button).toBeVisible()
  515. await button.click()
  516. }
  517. export async function clickListItem(
  518. container: Locator | Page,
  519. filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string },
  520. ): Promise<Locator> {
  521. let item: Locator
  522. if (typeof filter === "string" || filter instanceof RegExp) {
  523. item = container.locator(listItemSelector).filter({ hasText: filter }).first()
  524. } else if (filter.keyStartsWith) {
  525. item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first()
  526. } else if (filter.key) {
  527. item = container.locator(listItemKeySelector(filter.key)).first()
  528. } else if (filter.text) {
  529. item = container.locator(listItemSelector).filter({ hasText: filter.text }).first()
  530. } else {
  531. throw new Error("Invalid filter provided to clickListItem")
  532. }
  533. await expect(item).toBeVisible()
  534. await item.click()
  535. return item
  536. }
  537. async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
  538. const data = await sdk.session
  539. .status()
  540. .then((x) => x.data ?? {})
  541. .catch(() => undefined)
  542. return data?.[sessionID]
  543. }
  544. async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
  545. let prev = ""
  546. await expect
  547. .poll(
  548. async () => {
  549. const info = await sdk.session
  550. .get({ sessionID })
  551. .then((x) => x.data)
  552. .catch(() => undefined)
  553. if (!info) return true
  554. const next = `${info.title}:${info.time.updated ?? info.time.created}`
  555. if (next !== prev) {
  556. prev = next
  557. return false
  558. }
  559. return true
  560. },
  561. { timeout },
  562. )
  563. .toBe(true)
  564. }
  565. export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
  566. await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
  567. }
  568. export async function cleanupSession(input: {
  569. sessionID: string
  570. directory?: string
  571. sdk?: ReturnType<typeof createSdk>
  572. }) {
  573. const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
  574. if (!sdk) throw new Error("cleanupSession requires sdk or directory")
  575. await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
  576. const current = await status(sdk, input.sessionID).catch(() => undefined)
  577. if (current && current.type !== "idle") {
  578. await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
  579. await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
  580. }
  581. await stable(sdk, input.sessionID).catch(() => undefined)
  582. await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
  583. }
  584. export async function withSession<T>(
  585. sdk: ReturnType<typeof createSdk>,
  586. title: string,
  587. callback: (session: { id: string; title: string }) => Promise<T>,
  588. ): Promise<T> {
  589. const session = await sdk.session.create({ title }).then((r) => r.data)
  590. if (!session?.id) throw new Error("Session create did not return an id")
  591. try {
  592. return await callback(session)
  593. } finally {
  594. await cleanupSession({ sdk, sessionID: session.id })
  595. }
  596. }
  597. const seedSystem = [
  598. "You are seeding deterministic e2e UI state.",
  599. "Follow the user's instruction exactly.",
  600. "When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
  601. "Do not call any extra tools.",
  602. ].join(" ")
  603. const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
  604. const timeout = input.timeout ?? 30_000
  605. const end = Date.now() + timeout
  606. while (Date.now() < end) {
  607. const value = await input.probe()
  608. if (value !== undefined) return value
  609. await new Promise((resolve) => setTimeout(resolve, 250))
  610. }
  611. }
  612. const seed = async <T>(input: {
  613. sessionID: string
  614. prompt: string
  615. sdk: ReturnType<typeof createSdk>
  616. probe: () => Promise<T | undefined>
  617. timeout?: number
  618. attempts?: number
  619. }) => {
  620. for (let i = 0; i < (input.attempts ?? 2); i++) {
  621. await input.sdk.session.promptAsync({
  622. sessionID: input.sessionID,
  623. agent: "build",
  624. system: seedSystem,
  625. parts: [{ type: "text", text: input.prompt }],
  626. })
  627. const value = await wait({ probe: input.probe, timeout: input.timeout })
  628. if (value !== undefined) return value
  629. }
  630. }
  631. export async function seedSessionQuestion(
  632. sdk: ReturnType<typeof createSdk>,
  633. input: {
  634. sessionID: string
  635. questions: Array<{
  636. header: string
  637. question: string
  638. options: Array<{ label: string; description: string }>
  639. multiple?: boolean
  640. custom?: boolean
  641. }>
  642. },
  643. ) {
  644. const first = input.questions[0]
  645. if (!first) throw new Error("Question seed requires at least one question")
  646. const text = [
  647. "Your only valid response is one question tool call.",
  648. `Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
  649. "Do not output plain text.",
  650. "After calling the tool, wait for the user response.",
  651. ].join("\n")
  652. const result = await seed({
  653. sdk,
  654. sessionID: input.sessionID,
  655. prompt: text,
  656. timeout: 30_000,
  657. probe: async () => {
  658. const list = await sdk.question.list().then((x) => x.data ?? [])
  659. return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
  660. },
  661. })
  662. if (!result) throw new Error("Timed out seeding question request")
  663. return { id: result.id }
  664. }
  665. export async function seedSessionPermission(
  666. sdk: ReturnType<typeof createSdk>,
  667. input: {
  668. sessionID: string
  669. permission: string
  670. patterns: string[]
  671. description?: string
  672. },
  673. ) {
  674. const text = [
  675. "Your only valid response is one bash tool call.",
  676. `Use this JSON input: ${JSON.stringify({
  677. command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
  678. workdir: "/",
  679. description: input.description ?? `seed ${input.permission} permission request`,
  680. })}`,
  681. "Do not output plain text.",
  682. ].join("\n")
  683. const result = await seed({
  684. sdk,
  685. sessionID: input.sessionID,
  686. prompt: text,
  687. timeout: 30_000,
  688. probe: async () => {
  689. const list = await sdk.permission.list().then((x) => x.data ?? [])
  690. return list.find((item) => item.sessionID === input.sessionID)
  691. },
  692. })
  693. if (!result) throw new Error("Timed out seeding permission request")
  694. return { id: result.id }
  695. }
  696. export async function seedSessionTask(
  697. sdk: ReturnType<typeof createSdk>,
  698. input: {
  699. sessionID: string
  700. description: string
  701. prompt: string
  702. subagentType?: string
  703. },
  704. ) {
  705. const text = [
  706. "Your only valid response is one task tool call.",
  707. `Use this JSON input: ${JSON.stringify({
  708. description: input.description,
  709. prompt: input.prompt,
  710. subagent_type: input.subagentType ?? "general",
  711. })}`,
  712. "Do not output plain text.",
  713. "Wait for the task to start and return the child session id.",
  714. ].join("\n")
  715. const result = await seed({
  716. sdk,
  717. sessionID: input.sessionID,
  718. prompt: text,
  719. timeout: 90_000,
  720. probe: async () => {
  721. const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
  722. const part = messages
  723. .flatMap((message) => message.parts)
  724. .find((part) => {
  725. if (part.type !== "tool" || part.tool !== "task") return false
  726. if (!("state" in part) || !part.state || typeof part.state !== "object") return false
  727. if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false
  728. if (!("description" in part.state.input) || part.state.input.description !== input.description) return false
  729. if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object")
  730. return false
  731. if (!("sessionId" in part.state.metadata)) return false
  732. return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0
  733. })
  734. if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return
  735. if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
  736. if (!("sessionId" in part.state.metadata)) return
  737. const id = part.state.metadata.sessionId
  738. if (typeof id !== "string" || !id) return
  739. const child = await sdk.session
  740. .get({ sessionID: id })
  741. .then((x) => x.data)
  742. .catch(() => undefined)
  743. if (!child?.id) return
  744. return { sessionID: id }
  745. },
  746. })
  747. if (!result) throw new Error("Timed out seeding task tool")
  748. return result
  749. }
  750. export async function seedSessionTodos(
  751. sdk: ReturnType<typeof createSdk>,
  752. input: {
  753. sessionID: string
  754. todos: Array<{ content: string; status: string; priority: string }>
  755. },
  756. ) {
  757. const text = [
  758. "Your only valid response is one todowrite tool call.",
  759. `Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
  760. "Do not output plain text.",
  761. ].join("\n")
  762. const target = JSON.stringify(input.todos)
  763. const result = await seed({
  764. sdk,
  765. sessionID: input.sessionID,
  766. prompt: text,
  767. timeout: 30_000,
  768. probe: async () => {
  769. const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
  770. if (JSON.stringify(todos) !== target) return
  771. return true
  772. },
  773. })
  774. if (!result) throw new Error("Timed out seeding todos")
  775. return true
  776. }
  777. export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
  778. const [questions, permissions] = await Promise.all([
  779. sdk.question.list().then((x) => x.data ?? []),
  780. sdk.permission.list().then((x) => x.data ?? []),
  781. ])
  782. await Promise.all([
  783. ...questions
  784. .filter((item) => item.sessionID === sessionID)
  785. .map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
  786. ...permissions
  787. .filter((item) => item.sessionID === sessionID)
  788. .map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
  789. ])
  790. return true
  791. }
  792. export async function openStatusPopover(page: Page) {
  793. await defocus(page)
  794. const rightSection = page.locator(titlebarRightSelector)
  795. const trigger = rightSection.getByRole("button", { name: /status/i }).first()
  796. const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') })
  797. const opened = await popoverBody
  798. .isVisible()
  799. .then((x) => x)
  800. .catch(() => false)
  801. if (!opened) {
  802. await expect(trigger).toBeVisible()
  803. await trigger.click()
  804. await expect(popoverBody).toBeVisible()
  805. }
  806. return { rightSection, popoverBody }
  807. }
  808. export async function openProjectMenu(page: Page, projectSlug: string) {
  809. await openSidebar(page)
  810. const item = page.locator(projectSwitchSelector(projectSlug)).first()
  811. await expect(item).toBeVisible()
  812. await item.hover()
  813. const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
  814. await expect(trigger).toHaveCount(1)
  815. await expect(trigger).toBeVisible()
  816. const menu = page
  817. .locator(dropdownMenuContentSelector)
  818. .filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
  819. .first()
  820. const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
  821. const clicked = await trigger
  822. .click({ force: true, timeout: 1500 })
  823. .then(() => true)
  824. .catch(() => false)
  825. if (clicked) {
  826. const opened = await menu
  827. .waitFor({ state: "visible", timeout: 1500 })
  828. .then(() => true)
  829. .catch(() => false)
  830. if (opened) {
  831. await expect(close).toBeVisible()
  832. return menu
  833. }
  834. }
  835. await trigger.focus()
  836. await page.keyboard.press("Enter")
  837. const opened = await menu
  838. .waitFor({ state: "visible", timeout: 1500 })
  839. .then(() => true)
  840. .catch(() => false)
  841. if (opened) {
  842. await expect(close).toBeVisible()
  843. return menu
  844. }
  845. throw new Error(`Failed to open project menu: ${projectSlug}`)
  846. }
  847. export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
  848. const current = await page
  849. .getByRole("button", { name: "New workspace" })
  850. .first()
  851. .isVisible()
  852. .then((x) => x)
  853. .catch(() => false)
  854. if (current === enabled) return
  855. const flip = async (timeout?: number) => {
  856. const menu = await openProjectMenu(page, projectSlug)
  857. const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
  858. await expect(toggle).toBeVisible()
  859. return toggle.click({ force: true, timeout })
  860. }
  861. const flipped = await flip(1500)
  862. .then(() => true)
  863. .catch(() => false)
  864. if (!flipped) await flip()
  865. const expected = enabled ? "New workspace" : "New session"
  866. await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
  867. }
  868. export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
  869. const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
  870. await expect(item).toBeVisible()
  871. await item.hover()
  872. const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
  873. await expect(trigger).toBeVisible()
  874. await trigger.click({ force: true })
  875. const menu = page.locator(dropdownMenuContentSelector).first()
  876. await expect(menu).toBeVisible()
  877. return menu
  878. }