actions.ts 23 KB

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