actions.ts 25 KB

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