actions.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948
  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. export 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. export 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 add -A", { cwd: root, stdio: "ignore" })
  273. execSync('git -c user.name="e2e" -c user.email="[email protected]" commit -m "init" --allow-empty', {
  274. cwd: root,
  275. stdio: "ignore",
  276. })
  277. return resolveDirectory(root, input?.serverUrl)
  278. }
  279. export async function cleanupTestProject(directory: string) {
  280. try {
  281. execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
  282. } catch {}
  283. await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
  284. }
  285. export function slugFromUrl(url: string) {
  286. return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
  287. }
  288. async function probeSession(page: Page) {
  289. return page
  290. .evaluate(() => {
  291. const win = window as E2EWindow
  292. const current = win.__opencode_e2e?.model?.current
  293. if (!current) return null
  294. return { dir: current.dir, sessionID: current.sessionID }
  295. })
  296. .catch(() => null as { dir?: string; sessionID?: string } | null)
  297. }
  298. export async function waitSlug(page: Page, skip: string[] = []) {
  299. let prev = ""
  300. let next = ""
  301. await expect
  302. .poll(
  303. async () => {
  304. await assertHealthy(page, "waitSlug")
  305. const slug = slugFromUrl(page.url())
  306. if (!slug) return ""
  307. if (skip.includes(slug)) return ""
  308. if (slug !== prev) {
  309. prev = slug
  310. next = ""
  311. return ""
  312. }
  313. next = slug
  314. return slug
  315. },
  316. { timeout: 45_000 },
  317. )
  318. .not.toBe("")
  319. return next
  320. }
  321. export async function resolveSlug(slug: string, input?: { serverUrl?: string }) {
  322. const directory = base64Decode(slug)
  323. if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
  324. const resolved = await resolveDirectory(directory, input?.serverUrl)
  325. return { directory: resolved, slug: base64Encode(resolved), raw: slug }
  326. }
  327. export async function waitDir(page: Page, directory: string, input?: { serverUrl?: string }) {
  328. const target = await resolveDirectory(directory, input?.serverUrl)
  329. await expect
  330. .poll(
  331. async () => {
  332. await assertHealthy(page, "waitDir")
  333. const slug = slugFromUrl(page.url())
  334. if (!slug) return ""
  335. return resolveSlug(slug, input)
  336. .then((item) => item.directory)
  337. .catch(() => "")
  338. },
  339. { timeout: 45_000 },
  340. )
  341. .toBe(target)
  342. return { directory: target, slug: base64Encode(target) }
  343. }
  344. export async function waitSession(
  345. page: Page,
  346. input: {
  347. directory: string
  348. sessionID?: string
  349. serverUrl?: string
  350. allowAnySession?: boolean
  351. },
  352. ) {
  353. const target = await resolveDirectory(input.directory, input.serverUrl)
  354. await expect
  355. .poll(
  356. async () => {
  357. await assertHealthy(page, "waitSession")
  358. const slug = slugFromUrl(page.url())
  359. if (!slug) return false
  360. const resolved = await resolveSlug(slug, { serverUrl: input.serverUrl }).catch(() => undefined)
  361. if (!resolved || resolved.directory !== target) return false
  362. const current = sessionIDFromUrl(page.url())
  363. if (input.sessionID && current !== input.sessionID) return false
  364. if (!input.sessionID && !input.allowAnySession && current) return false
  365. const state = await probeSession(page)
  366. if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
  367. if (!input.sessionID && !input.allowAnySession && state?.sessionID) return false
  368. if (state?.dir) {
  369. const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
  370. if (dir !== target) return false
  371. }
  372. return page
  373. .locator(promptSelector)
  374. .first()
  375. .isVisible()
  376. .catch(() => false)
  377. },
  378. { timeout: 45_000 },
  379. )
  380. .toBe(true)
  381. return { directory: target, slug: base64Encode(target) }
  382. }
  383. export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000, serverUrl?: string) {
  384. const sdk = createSdk(directory, serverUrl)
  385. const target = await resolveDirectory(directory, serverUrl)
  386. await expect
  387. .poll(
  388. async () => {
  389. const data = await sdk.session
  390. .get({ sessionID })
  391. .then((x) => x.data)
  392. .catch(() => undefined)
  393. if (!data?.directory) return ""
  394. return resolveDirectory(data.directory, serverUrl).catch(() => data.directory)
  395. },
  396. { timeout },
  397. )
  398. .toBe(target)
  399. await expect
  400. .poll(
  401. async () => {
  402. const items = await sdk.session
  403. .messages({ sessionID, limit: 20 })
  404. .then((x) => x.data ?? [])
  405. .catch(() => [])
  406. return items.some((item) => item.info.role === "user")
  407. },
  408. { timeout },
  409. )
  410. .toBe(true)
  411. }
  412. export function sessionIDFromUrl(url: string) {
  413. const match = /\/session\/([^/?#]+)/.exec(url)
  414. return match?.[1]
  415. }
  416. export async function hoverSessionItem(page: Page, sessionID: string) {
  417. const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
  418. await expect(sessionEl).toBeVisible()
  419. await sessionEl.hover()
  420. return sessionEl
  421. }
  422. export async function openSessionMoreMenu(page: Page, sessionID: string) {
  423. await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
  424. const scroller = page.locator(".scroll-view__viewport").first()
  425. await expect(scroller).toBeVisible()
  426. await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
  427. const menu = page
  428. .locator(dropdownMenuContentSelector)
  429. .filter({ has: page.getByRole("menuitem", { name: /rename/i }) })
  430. .filter({ has: page.getByRole("menuitem", { name: /archive/i }) })
  431. .filter({ has: page.getByRole("menuitem", { name: /delete/i }) })
  432. .first()
  433. const opened = await menu
  434. .isVisible()
  435. .then((x) => x)
  436. .catch(() => false)
  437. if (opened) return menu
  438. const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
  439. await expect(menuTrigger).toBeVisible()
  440. await menuTrigger.click()
  441. await expect(menu).toBeVisible()
  442. return menu
  443. }
  444. export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) {
  445. const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first()
  446. await expect(item).toBeVisible()
  447. await item.click({ force: options?.force })
  448. }
  449. export async function confirmDialog(page: Page, buttonName: string | RegExp) {
  450. const dialog = page.getByRole("dialog").first()
  451. await expect(dialog).toBeVisible()
  452. const button = dialog.getByRole("button").filter({ hasText: buttonName }).first()
  453. await expect(button).toBeVisible()
  454. await button.click()
  455. }
  456. export async function openSharePopover(page: Page) {
  457. const scroller = page.locator(".scroll-view__viewport").first()
  458. await expect(scroller).toBeVisible()
  459. await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
  460. const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
  461. await expect(menuTrigger).toBeVisible({ timeout: 30_000 })
  462. const popoverBody = page
  463. .locator('[data-component="popover-content"]')
  464. .filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
  465. .first()
  466. const opened = await popoverBody
  467. .isVisible()
  468. .then((x) => x)
  469. .catch(() => false)
  470. if (!opened) {
  471. const menu = page.locator(dropdownMenuContentSelector).first()
  472. await menuTrigger.click()
  473. await clickMenuItem(menu, /share/i)
  474. await expect(menu).toHaveCount(0)
  475. await expect(popoverBody).toBeVisible({ timeout: 30_000 })
  476. }
  477. return { rightSection: scroller, popoverBody }
  478. }
  479. export async function clickListItem(
  480. container: Locator | Page,
  481. filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string },
  482. ): Promise<Locator> {
  483. let item: Locator
  484. if (typeof filter === "string" || filter instanceof RegExp) {
  485. item = container.locator(listItemSelector).filter({ hasText: filter }).first()
  486. } else if (filter.keyStartsWith) {
  487. item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first()
  488. } else if (filter.key) {
  489. item = container.locator(listItemKeySelector(filter.key)).first()
  490. } else if (filter.text) {
  491. item = container.locator(listItemSelector).filter({ hasText: filter.text }).first()
  492. } else {
  493. throw new Error("Invalid filter provided to clickListItem")
  494. }
  495. await expect(item).toBeVisible()
  496. await item.click()
  497. return item
  498. }
  499. async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
  500. const data = await sdk.session
  501. .status()
  502. .then((x) => x.data ?? {})
  503. .catch(() => undefined)
  504. return data?.[sessionID]
  505. }
  506. async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
  507. let prev = ""
  508. await expect
  509. .poll(
  510. async () => {
  511. const info = await sdk.session
  512. .get({ sessionID })
  513. .then((x) => x.data)
  514. .catch(() => undefined)
  515. if (!info) return true
  516. const next = `${info.title}:${info.time.updated ?? info.time.created}`
  517. if (next !== prev) {
  518. prev = next
  519. return false
  520. }
  521. return true
  522. },
  523. { timeout },
  524. )
  525. .toBe(true)
  526. }
  527. export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
  528. await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
  529. }
  530. export async function cleanupSession(input: {
  531. sessionID: string
  532. directory?: string
  533. sdk?: ReturnType<typeof createSdk>
  534. serverUrl?: string
  535. }) {
  536. const sdk = input.sdk ?? (input.directory ? createSdk(input.directory, input.serverUrl) : undefined)
  537. if (!sdk) throw new Error("cleanupSession requires sdk or directory")
  538. await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
  539. const current = await status(sdk, input.sessionID).catch(() => undefined)
  540. if (current && current.type !== "idle") {
  541. await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
  542. await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
  543. }
  544. await stable(sdk, input.sessionID).catch(() => undefined)
  545. await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
  546. }
  547. export async function withSession<T>(
  548. sdk: ReturnType<typeof createSdk>,
  549. title: string,
  550. callback: (session: { id: string; title: string }) => Promise<T>,
  551. ): Promise<T> {
  552. const session = await sdk.session.create({ title }).then((r) => r.data)
  553. if (!session?.id) throw new Error("Session create did not return an id")
  554. try {
  555. return await callback(session)
  556. } finally {
  557. await cleanupSession({ sdk, sessionID: session.id })
  558. }
  559. }
  560. const seedSystem = [
  561. "You are seeding deterministic e2e UI state.",
  562. "Follow the user's instruction exactly.",
  563. "When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
  564. "Do not call any extra tools.",
  565. ].join(" ")
  566. const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
  567. const timeout = input.timeout ?? 30_000
  568. const end = Date.now() + timeout
  569. while (Date.now() < end) {
  570. const value = await input.probe()
  571. if (value !== undefined) return value
  572. await new Promise((resolve) => setTimeout(resolve, 250))
  573. }
  574. }
  575. const seed = async <T>(input: {
  576. sessionID: string
  577. prompt: string
  578. sdk: ReturnType<typeof createSdk>
  579. probe: () => Promise<T | undefined>
  580. timeout?: number
  581. attempts?: number
  582. }) => {
  583. for (let i = 0; i < (input.attempts ?? 2); i++) {
  584. await input.sdk.session.promptAsync({
  585. sessionID: input.sessionID,
  586. agent: "build",
  587. system: seedSystem,
  588. parts: [{ type: "text", text: input.prompt }],
  589. })
  590. const value = await wait({ probe: input.probe, timeout: input.timeout })
  591. if (value !== undefined) return value
  592. }
  593. }
  594. export async function seedSessionQuestion(
  595. sdk: ReturnType<typeof createSdk>,
  596. input: {
  597. sessionID: string
  598. questions: Array<{
  599. header: string
  600. question: string
  601. options: Array<{ label: string; description: string }>
  602. multiple?: boolean
  603. custom?: boolean
  604. }>
  605. },
  606. ) {
  607. const first = input.questions[0]
  608. if (!first) throw new Error("Question seed requires at least one question")
  609. const text = [
  610. "Your only valid response is one question tool call.",
  611. `Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
  612. "Do not output plain text.",
  613. "After calling the tool, wait for the user response.",
  614. ].join("\n")
  615. const result = await seed({
  616. sdk,
  617. sessionID: input.sessionID,
  618. prompt: text,
  619. timeout: 30_000,
  620. probe: async () => {
  621. const list = await sdk.question.list().then((x) => x.data ?? [])
  622. return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
  623. },
  624. })
  625. if (!result) throw new Error("Timed out seeding question request")
  626. return { id: result.id }
  627. }
  628. export async function seedSessionTask(
  629. sdk: ReturnType<typeof createSdk>,
  630. input: {
  631. sessionID: string
  632. description: string
  633. prompt: string
  634. subagentType?: string
  635. },
  636. ) {
  637. const text = [
  638. "Your only valid response is one task tool call.",
  639. `Use this JSON input: ${JSON.stringify({
  640. description: input.description,
  641. prompt: input.prompt,
  642. subagent_type: input.subagentType ?? "general",
  643. })}`,
  644. "Do not output plain text.",
  645. "Wait for the task to start and return the child session id.",
  646. ].join("\n")
  647. const result = await seed({
  648. sdk,
  649. sessionID: input.sessionID,
  650. prompt: text,
  651. timeout: 90_000,
  652. probe: async () => {
  653. const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
  654. const part = messages
  655. .flatMap((message) => message.parts)
  656. .find((part) => {
  657. if (part.type !== "tool" || part.tool !== "task") return false
  658. if (!("state" in part) || !part.state || typeof part.state !== "object") return false
  659. if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false
  660. if (!("description" in part.state.input) || part.state.input.description !== input.description) return false
  661. if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object")
  662. return false
  663. if (!("sessionId" in part.state.metadata)) return false
  664. return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0
  665. })
  666. if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return
  667. if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
  668. if (!("sessionId" in part.state.metadata)) return
  669. const id = part.state.metadata.sessionId
  670. if (typeof id !== "string" || !id) return
  671. const child = await sdk.session
  672. .get({ sessionID: id })
  673. .then((x) => x.data)
  674. .catch(() => undefined)
  675. if (!child?.id) return
  676. return { sessionID: id }
  677. },
  678. })
  679. if (!result) throw new Error("Timed out seeding task tool")
  680. return result
  681. }
  682. export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
  683. const [questions, permissions] = await Promise.all([
  684. sdk.question.list().then((x) => x.data ?? []),
  685. sdk.permission.list().then((x) => x.data ?? []),
  686. ])
  687. await Promise.all([
  688. ...questions
  689. .filter((item) => item.sessionID === sessionID)
  690. .map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
  691. ...permissions
  692. .filter((item) => item.sessionID === sessionID)
  693. .map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
  694. ])
  695. return true
  696. }
  697. export async function openStatusPopover(page: Page) {
  698. await defocus(page)
  699. const rightSection = page.locator(titlebarRightSelector)
  700. const trigger = rightSection.getByRole("button", { name: /status/i }).first()
  701. const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') })
  702. const opened = await popoverBody
  703. .isVisible()
  704. .then((x) => x)
  705. .catch(() => false)
  706. if (!opened) {
  707. await expect(trigger).toBeVisible()
  708. await trigger.click()
  709. await expect(popoverBody).toBeVisible()
  710. }
  711. return { rightSection, popoverBody }
  712. }
  713. export async function openProjectMenu(page: Page, projectSlug: string) {
  714. await openSidebar(page)
  715. const item = page.locator(projectSwitchSelector(projectSlug)).first()
  716. await expect(item).toBeVisible()
  717. await item.hover()
  718. const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
  719. await expect(trigger).toHaveCount(1)
  720. await expect(trigger).toBeVisible()
  721. const menu = page
  722. .locator(dropdownMenuContentSelector)
  723. .filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
  724. .first()
  725. const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
  726. const clicked = await trigger
  727. .click({ force: true, timeout: 1500 })
  728. .then(() => true)
  729. .catch(() => false)
  730. if (clicked) {
  731. const opened = await menu
  732. .waitFor({ state: "visible", timeout: 1500 })
  733. .then(() => true)
  734. .catch(() => false)
  735. if (opened) {
  736. await expect(close).toBeVisible()
  737. return menu
  738. }
  739. }
  740. await trigger.focus()
  741. await page.keyboard.press("Enter")
  742. const opened = await menu
  743. .waitFor({ state: "visible", timeout: 1500 })
  744. .then(() => true)
  745. .catch(() => false)
  746. if (opened) {
  747. await expect(close).toBeVisible()
  748. return menu
  749. }
  750. throw new Error(`Failed to open project menu: ${projectSlug}`)
  751. }
  752. export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
  753. const current = () =>
  754. page
  755. .getByRole("button", { name: "New workspace" })
  756. .first()
  757. .isVisible()
  758. .then((x) => x)
  759. .catch(() => false)
  760. if ((await current()) === enabled) return
  761. if (enabled) {
  762. await page.reload()
  763. await openSidebar(page)
  764. if ((await current()) === enabled) return
  765. }
  766. const flip = async (timeout?: number) => {
  767. const menu = await openProjectMenu(page, projectSlug)
  768. const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
  769. await expect(toggle).toBeVisible()
  770. await expect(toggle).toBeEnabled({ timeout: 30_000 })
  771. const clicked = await toggle
  772. .click({ force: true, timeout })
  773. .then(() => true)
  774. .catch(() => false)
  775. if (clicked) return
  776. await toggle.focus()
  777. await page.keyboard.press("Enter")
  778. }
  779. for (const timeout of [1500, undefined, undefined]) {
  780. if ((await current()) === enabled) break
  781. await flip(timeout)
  782. .then(() => undefined)
  783. .catch(() => undefined)
  784. const matched = await expect
  785. .poll(current, { timeout: 5_000 })
  786. .toBe(enabled)
  787. .then(() => true)
  788. .catch(() => false)
  789. if (matched) break
  790. }
  791. if ((await current()) !== enabled) {
  792. await page.reload()
  793. await openSidebar(page)
  794. }
  795. const expected = enabled ? "New workspace" : "New session"
  796. await expect.poll(current, { timeout: 60_000 }).toBe(enabled)
  797. await expect(page.getByRole("button", { name: expected }).first()).toBeVisible({ timeout: 30_000 })
  798. }
  799. export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
  800. const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
  801. await expect(item).toBeVisible()
  802. await item.hover()
  803. const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
  804. await expect(trigger).toBeVisible()
  805. await trigger.click({ force: true })
  806. const menu = page.locator(dropdownMenuContentSelector).first()
  807. await expect(menu).toBeVisible()
  808. return menu
  809. }
  810. export async function assistantText(sdk: ReturnType<typeof createSdk>, sessionID: string) {
  811. const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
  812. return messages
  813. .filter((m) => m.info.role === "assistant")
  814. .flatMap((m) => m.parts)
  815. .filter((p) => p.type === "text")
  816. .map((p) => p.text)
  817. .join("\n")
  818. }