actions.ts 30 KB

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