actions.ts 27 KB

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