actions.ts 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021
  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. const current = sessionIDFromUrl(page.url())
  404. if (input.sessionID && current !== input.sessionID) return false
  405. if (!input.sessionID && current) return false
  406. const state = await probeSession(page)
  407. if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
  408. if (!input.sessionID && state?.sessionID) return false
  409. if (state?.dir) {
  410. const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
  411. if (dir !== target) return false
  412. }
  413. return page
  414. .locator(promptSelector)
  415. .first()
  416. .isVisible()
  417. .catch(() => false)
  418. },
  419. { timeout: 45_000 },
  420. )
  421. .toBe(true)
  422. return { directory: target, slug: base64Encode(target) }
  423. }
  424. export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
  425. const sdk = createSdk(directory)
  426. const target = await resolveDirectory(directory)
  427. await expect
  428. .poll(
  429. async () => {
  430. const data = await sdk.session
  431. .get({ sessionID })
  432. .then((x) => x.data)
  433. .catch(() => undefined)
  434. if (!data?.directory) return ""
  435. return resolveDirectory(data.directory).catch(() => data.directory)
  436. },
  437. { timeout },
  438. )
  439. .toBe(target)
  440. await expect
  441. .poll(
  442. async () => {
  443. const items = await sdk.session
  444. .messages({ sessionID, limit: 20 })
  445. .then((x) => x.data ?? [])
  446. .catch(() => [])
  447. return items.some((item) => item.info.role === "user")
  448. },
  449. { timeout },
  450. )
  451. .toBe(true)
  452. }
  453. export function sessionIDFromUrl(url: string) {
  454. const match = /\/session\/([^/?#]+)/.exec(url)
  455. return match?.[1]
  456. }
  457. export async function hoverSessionItem(page: Page, sessionID: string) {
  458. const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
  459. await expect(sessionEl).toBeVisible()
  460. await sessionEl.hover()
  461. return sessionEl
  462. }
  463. export async function openSessionMoreMenu(page: Page, sessionID: string) {
  464. await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
  465. const scroller = page.locator(".scroll-view__viewport").first()
  466. await expect(scroller).toBeVisible()
  467. await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
  468. const menu = page
  469. .locator(dropdownMenuContentSelector)
  470. .filter({ has: page.getByRole("menuitem", { name: /rename/i }) })
  471. .filter({ has: page.getByRole("menuitem", { name: /archive/i }) })
  472. .filter({ has: page.getByRole("menuitem", { name: /delete/i }) })
  473. .first()
  474. const opened = await menu
  475. .isVisible()
  476. .then((x) => x)
  477. .catch(() => false)
  478. if (opened) return menu
  479. const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
  480. await expect(menuTrigger).toBeVisible()
  481. await menuTrigger.click()
  482. await expect(menu).toBeVisible()
  483. return menu
  484. }
  485. export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) {
  486. const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first()
  487. await expect(item).toBeVisible()
  488. await item.click({ force: options?.force })
  489. }
  490. export async function confirmDialog(page: Page, buttonName: string | RegExp) {
  491. const dialog = page.getByRole("dialog").first()
  492. await expect(dialog).toBeVisible()
  493. const button = dialog.getByRole("button").filter({ hasText: buttonName }).first()
  494. await expect(button).toBeVisible()
  495. await button.click()
  496. }
  497. export async function openSharePopover(page: Page) {
  498. const rightSection = page.locator(titlebarRightSelector)
  499. const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
  500. await expect(shareButton).toBeVisible()
  501. const popoverBody = page
  502. .locator(popoverBodySelector)
  503. .filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
  504. .first()
  505. const opened = await popoverBody
  506. .isVisible()
  507. .then((x) => x)
  508. .catch(() => false)
  509. if (!opened) {
  510. await shareButton.click()
  511. await expect(popoverBody).toBeVisible()
  512. }
  513. return { rightSection, popoverBody }
  514. }
  515. export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
  516. const button = page.getByRole("button").filter({ hasText: buttonName }).first()
  517. await expect(button).toBeVisible()
  518. await button.click()
  519. }
  520. export async function clickListItem(
  521. container: Locator | Page,
  522. filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string },
  523. ): Promise<Locator> {
  524. let item: Locator
  525. if (typeof filter === "string" || filter instanceof RegExp) {
  526. item = container.locator(listItemSelector).filter({ hasText: filter }).first()
  527. } else if (filter.keyStartsWith) {
  528. item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first()
  529. } else if (filter.key) {
  530. item = container.locator(listItemKeySelector(filter.key)).first()
  531. } else if (filter.text) {
  532. item = container.locator(listItemSelector).filter({ hasText: filter.text }).first()
  533. } else {
  534. throw new Error("Invalid filter provided to clickListItem")
  535. }
  536. await expect(item).toBeVisible()
  537. await item.click()
  538. return item
  539. }
  540. async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
  541. const data = await sdk.session
  542. .status()
  543. .then((x) => x.data ?? {})
  544. .catch(() => undefined)
  545. return data?.[sessionID]
  546. }
  547. async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
  548. let prev = ""
  549. await expect
  550. .poll(
  551. async () => {
  552. const info = await sdk.session
  553. .get({ sessionID })
  554. .then((x) => x.data)
  555. .catch(() => undefined)
  556. if (!info) return true
  557. const next = `${info.title}:${info.time.updated ?? info.time.created}`
  558. if (next !== prev) {
  559. prev = next
  560. return false
  561. }
  562. return true
  563. },
  564. { timeout },
  565. )
  566. .toBe(true)
  567. }
  568. export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
  569. await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
  570. }
  571. export async function cleanupSession(input: {
  572. sessionID: string
  573. directory?: string
  574. sdk?: ReturnType<typeof createSdk>
  575. }) {
  576. const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
  577. if (!sdk) throw new Error("cleanupSession requires sdk or directory")
  578. await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
  579. const current = await status(sdk, input.sessionID).catch(() => undefined)
  580. if (current && current.type !== "idle") {
  581. await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
  582. await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
  583. }
  584. await stable(sdk, input.sessionID).catch(() => undefined)
  585. await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
  586. }
  587. export async function withSession<T>(
  588. sdk: ReturnType<typeof createSdk>,
  589. title: string,
  590. callback: (session: { id: string; title: string }) => Promise<T>,
  591. ): Promise<T> {
  592. const session = await sdk.session.create({ title }).then((r) => r.data)
  593. if (!session?.id) throw new Error("Session create did not return an id")
  594. try {
  595. return await callback(session)
  596. } finally {
  597. await cleanupSession({ sdk, sessionID: session.id })
  598. }
  599. }
  600. const seedSystem = [
  601. "You are seeding deterministic e2e UI state.",
  602. "Follow the user's instruction exactly.",
  603. "When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
  604. "Do not call any extra tools.",
  605. ].join(" ")
  606. const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
  607. const timeout = input.timeout ?? 30_000
  608. const end = Date.now() + timeout
  609. while (Date.now() < end) {
  610. const value = await input.probe()
  611. if (value !== undefined) return value
  612. await new Promise((resolve) => setTimeout(resolve, 250))
  613. }
  614. }
  615. const seed = async <T>(input: {
  616. sessionID: string
  617. prompt: string
  618. sdk: ReturnType<typeof createSdk>
  619. probe: () => Promise<T | undefined>
  620. timeout?: number
  621. attempts?: number
  622. }) => {
  623. for (let i = 0; i < (input.attempts ?? 2); i++) {
  624. await input.sdk.session.promptAsync({
  625. sessionID: input.sessionID,
  626. agent: "build",
  627. system: seedSystem,
  628. parts: [{ type: "text", text: input.prompt }],
  629. })
  630. const value = await wait({ probe: input.probe, timeout: input.timeout })
  631. if (value !== undefined) return value
  632. }
  633. }
  634. export async function seedSessionQuestion(
  635. sdk: ReturnType<typeof createSdk>,
  636. input: {
  637. sessionID: string
  638. questions: Array<{
  639. header: string
  640. question: string
  641. options: Array<{ label: string; description: string }>
  642. multiple?: boolean
  643. custom?: boolean
  644. }>
  645. },
  646. ) {
  647. const first = input.questions[0]
  648. if (!first) throw new Error("Question seed requires at least one question")
  649. const text = [
  650. "Your only valid response is one question tool call.",
  651. `Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
  652. "Do not output plain text.",
  653. "After calling the tool, wait for the user response.",
  654. ].join("\n")
  655. const result = await seed({
  656. sdk,
  657. sessionID: input.sessionID,
  658. prompt: text,
  659. timeout: 30_000,
  660. probe: async () => {
  661. const list = await sdk.question.list().then((x) => x.data ?? [])
  662. return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
  663. },
  664. })
  665. if (!result) throw new Error("Timed out seeding question request")
  666. return { id: result.id }
  667. }
  668. export async function seedSessionPermission(
  669. sdk: ReturnType<typeof createSdk>,
  670. input: {
  671. sessionID: string
  672. permission: string
  673. patterns: string[]
  674. description?: string
  675. },
  676. ) {
  677. const text = [
  678. "Your only valid response is one bash tool call.",
  679. `Use this JSON input: ${JSON.stringify({
  680. command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
  681. workdir: "/",
  682. description: input.description ?? `seed ${input.permission} permission request`,
  683. })}`,
  684. "Do not output plain text.",
  685. ].join("\n")
  686. const result = await seed({
  687. sdk,
  688. sessionID: input.sessionID,
  689. prompt: text,
  690. timeout: 30_000,
  691. probe: async () => {
  692. const list = await sdk.permission.list().then((x) => x.data ?? [])
  693. return list.find((item) => item.sessionID === input.sessionID)
  694. },
  695. })
  696. if (!result) throw new Error("Timed out seeding permission request")
  697. return { id: result.id }
  698. }
  699. export async function seedSessionTask(
  700. sdk: ReturnType<typeof createSdk>,
  701. input: {
  702. sessionID: string
  703. description: string
  704. prompt: string
  705. subagentType?: string
  706. },
  707. ) {
  708. const text = [
  709. "Your only valid response is one task tool call.",
  710. `Use this JSON input: ${JSON.stringify({
  711. description: input.description,
  712. prompt: input.prompt,
  713. subagent_type: input.subagentType ?? "general",
  714. })}`,
  715. "Do not output plain text.",
  716. "Wait for the task to start and return the child session id.",
  717. ].join("\n")
  718. const result = await seed({
  719. sdk,
  720. sessionID: input.sessionID,
  721. prompt: text,
  722. timeout: 90_000,
  723. probe: async () => {
  724. const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
  725. const part = messages
  726. .flatMap((message) => message.parts)
  727. .find((part) => {
  728. if (part.type !== "tool" || part.tool !== "task") return false
  729. if (!("state" in part) || !part.state || typeof part.state !== "object") return false
  730. if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false
  731. if (!("description" in part.state.input) || part.state.input.description !== input.description) return false
  732. if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object")
  733. return false
  734. if (!("sessionId" in part.state.metadata)) return false
  735. return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0
  736. })
  737. if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return
  738. if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
  739. if (!("sessionId" in part.state.metadata)) return
  740. const id = part.state.metadata.sessionId
  741. if (typeof id !== "string" || !id) return
  742. const child = await sdk.session
  743. .get({ sessionID: id })
  744. .then((x) => x.data)
  745. .catch(() => undefined)
  746. if (!child?.id) return
  747. return { sessionID: id }
  748. },
  749. })
  750. if (!result) throw new Error("Timed out seeding task tool")
  751. return result
  752. }
  753. export async function seedSessionTodos(
  754. sdk: ReturnType<typeof createSdk>,
  755. input: {
  756. sessionID: string
  757. todos: Array<{ content: string; status: string; priority: string }>
  758. },
  759. ) {
  760. const text = [
  761. "Your only valid response is one todowrite tool call.",
  762. `Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
  763. "Do not output plain text.",
  764. ].join("\n")
  765. const target = JSON.stringify(input.todos)
  766. const result = await seed({
  767. sdk,
  768. sessionID: input.sessionID,
  769. prompt: text,
  770. timeout: 30_000,
  771. probe: async () => {
  772. const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
  773. if (JSON.stringify(todos) !== target) return
  774. return true
  775. },
  776. })
  777. if (!result) throw new Error("Timed out seeding todos")
  778. return true
  779. }
  780. export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
  781. const [questions, permissions] = await Promise.all([
  782. sdk.question.list().then((x) => x.data ?? []),
  783. sdk.permission.list().then((x) => x.data ?? []),
  784. ])
  785. await Promise.all([
  786. ...questions
  787. .filter((item) => item.sessionID === sessionID)
  788. .map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
  789. ...permissions
  790. .filter((item) => item.sessionID === sessionID)
  791. .map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
  792. ])
  793. return true
  794. }
  795. export async function openStatusPopover(page: Page) {
  796. await defocus(page)
  797. const rightSection = page.locator(titlebarRightSelector)
  798. const trigger = rightSection.getByRole("button", { name: /status/i }).first()
  799. const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') })
  800. const opened = await popoverBody
  801. .isVisible()
  802. .then((x) => x)
  803. .catch(() => false)
  804. if (!opened) {
  805. await expect(trigger).toBeVisible()
  806. await trigger.click()
  807. await expect(popoverBody).toBeVisible()
  808. }
  809. return { rightSection, popoverBody }
  810. }
  811. export async function openProjectMenu(page: Page, projectSlug: string) {
  812. await openSidebar(page)
  813. const item = page.locator(projectSwitchSelector(projectSlug)).first()
  814. await expect(item).toBeVisible()
  815. await item.hover()
  816. const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
  817. await expect(trigger).toHaveCount(1)
  818. await expect(trigger).toBeVisible()
  819. const menu = page
  820. .locator(dropdownMenuContentSelector)
  821. .filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
  822. .first()
  823. const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
  824. const clicked = await trigger
  825. .click({ force: true, timeout: 1500 })
  826. .then(() => true)
  827. .catch(() => false)
  828. if (clicked) {
  829. const opened = await menu
  830. .waitFor({ state: "visible", timeout: 1500 })
  831. .then(() => true)
  832. .catch(() => false)
  833. if (opened) {
  834. await expect(close).toBeVisible()
  835. return menu
  836. }
  837. }
  838. await trigger.focus()
  839. await page.keyboard.press("Enter")
  840. const opened = await menu
  841. .waitFor({ state: "visible", timeout: 1500 })
  842. .then(() => true)
  843. .catch(() => false)
  844. if (opened) {
  845. await expect(close).toBeVisible()
  846. return menu
  847. }
  848. throw new Error(`Failed to open project menu: ${projectSlug}`)
  849. }
  850. export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
  851. const current = await page
  852. .getByRole("button", { name: "New workspace" })
  853. .first()
  854. .isVisible()
  855. .then((x) => x)
  856. .catch(() => false)
  857. if (current === enabled) return
  858. const flip = async (timeout?: number) => {
  859. const menu = await openProjectMenu(page, projectSlug)
  860. const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
  861. await expect(toggle).toBeVisible()
  862. return toggle.click({ force: true, timeout })
  863. }
  864. const flipped = await flip(1500)
  865. .then(() => true)
  866. .catch(() => false)
  867. if (!flipped) await flip()
  868. const expected = enabled ? "New workspace" : "New session"
  869. await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
  870. }
  871. export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
  872. const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
  873. await expect(item).toBeVisible()
  874. await item.hover()
  875. const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
  876. await expect(trigger).toBeVisible()
  877. await trigger.click({ force: true })
  878. const menu = page.locator(dropdownMenuContentSelector).first()
  879. await expect(menu).toBeVisible()
  880. return menu
  881. }