actions.ts 28 KB

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