session-composer-dock.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. import { test, expect } from "../fixtures"
  2. import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
  3. import {
  4. permissionDockSelector,
  5. promptSelector,
  6. questionDockSelector,
  7. sessionComposerDockSelector,
  8. sessionTodoDockSelector,
  9. sessionTodoListSelector,
  10. sessionTodoToggleButtonSelector,
  11. } from "../selectors"
  12. type Sdk = Parameters<typeof clearSessionDockSeed>[0]
  13. type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
  14. async function withDockSession<T>(
  15. sdk: Sdk,
  16. title: string,
  17. fn: (session: { id: string; title: string }) => Promise<T>,
  18. opts?: { permission?: PermissionRule[] },
  19. ) {
  20. const session = await sdk.session
  21. .create(opts?.permission ? { title, permission: opts.permission } : { title })
  22. .then((r) => r.data)
  23. if (!session?.id) throw new Error("Session create did not return an id")
  24. try {
  25. return await fn(session)
  26. } finally {
  27. await cleanupSession({ sdk, sessionID: session.id })
  28. }
  29. }
  30. test.setTimeout(120_000)
  31. async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
  32. try {
  33. return await fn()
  34. } finally {
  35. await clearSessionDockSeed(sdk, sessionID).catch(() => undefined)
  36. }
  37. }
  38. async function clearPermissionDock(page: any, label: RegExp) {
  39. const dock = page.locator(permissionDockSelector)
  40. for (let i = 0; i < 3; i++) {
  41. const count = await dock.count()
  42. if (count === 0) return
  43. await dock.getByRole("button", { name: label }).click()
  44. await page.waitForTimeout(150)
  45. }
  46. }
  47. async function setAutoAccept(page: any, enabled: boolean) {
  48. const button = page.locator('[data-action="prompt-permissions"]').first()
  49. await expect(button).toBeVisible()
  50. const pressed = (await button.getAttribute("aria-pressed")) === "true"
  51. if (pressed === enabled) return
  52. await button.click()
  53. await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
  54. }
  55. async function withMockPermission<T>(
  56. page: any,
  57. request: {
  58. id: string
  59. sessionID: string
  60. permission: string
  61. patterns: string[]
  62. metadata?: Record<string, unknown>
  63. always?: string[]
  64. },
  65. opts: { child?: any } | undefined,
  66. fn: () => Promise<T>,
  67. ) {
  68. let pending = [
  69. {
  70. ...request,
  71. always: request.always ?? ["*"],
  72. metadata: request.metadata ?? {},
  73. },
  74. ]
  75. const list = async (route: any) => {
  76. await route.fulfill({
  77. status: 200,
  78. contentType: "application/json",
  79. body: JSON.stringify(pending),
  80. })
  81. }
  82. const reply = async (route: any) => {
  83. const url = new URL(route.request().url())
  84. const id = url.pathname.split("/").pop()
  85. pending = pending.filter((item) => item.id !== id)
  86. await route.fulfill({
  87. status: 200,
  88. contentType: "application/json",
  89. body: JSON.stringify(true),
  90. })
  91. }
  92. await page.route("**/permission", list)
  93. await page.route("**/session/*/permissions/*", reply)
  94. const sessionList = opts?.child
  95. ? async (route: any) => {
  96. const res = await route.fetch()
  97. const json = await res.json()
  98. const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
  99. if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
  100. await route.fulfill({
  101. status: res.status(),
  102. headers: res.headers(),
  103. contentType: "application/json",
  104. body: JSON.stringify(json),
  105. })
  106. }
  107. : undefined
  108. if (sessionList) await page.route("**/session?*", sessionList)
  109. try {
  110. return await fn()
  111. } finally {
  112. await page.unroute("**/permission", list)
  113. await page.unroute("**/session/*/permissions/*", reply)
  114. if (sessionList) await page.unroute("**/session?*", sessionList)
  115. }
  116. }
  117. test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
  118. await withDockSession(sdk, "e2e composer dock default", async (session) => {
  119. await gotoSession(session.id)
  120. await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
  121. await expect(page.locator(promptSelector)).toBeVisible()
  122. await expect(page.locator(questionDockSelector)).toHaveCount(0)
  123. await expect(page.locator(permissionDockSelector)).toHaveCount(0)
  124. await page.locator(promptSelector).click()
  125. await expect(page.locator(promptSelector)).toBeFocused()
  126. })
  127. })
  128. test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
  129. await gotoSession()
  130. const button = page.locator('[data-action="prompt-permissions"]').first()
  131. await expect(button).toBeVisible()
  132. await expect(button).toHaveAttribute("aria-pressed", "false")
  133. await setAutoAccept(page, true)
  134. await setAutoAccept(page, false)
  135. })
  136. test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
  137. await withDockSession(sdk, "e2e composer dock question", async (session) => {
  138. await withDockSeed(sdk, session.id, async () => {
  139. await gotoSession(session.id)
  140. await seedSessionQuestion(sdk, {
  141. sessionID: session.id,
  142. questions: [
  143. {
  144. header: "Need input",
  145. question: "Pick one option",
  146. options: [
  147. { label: "Continue", description: "Continue now" },
  148. { label: "Stop", description: "Stop here" },
  149. ],
  150. },
  151. ],
  152. })
  153. const dock = page.locator(questionDockSelector)
  154. await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
  155. await expect(page.locator(promptSelector)).toHaveCount(0)
  156. await dock.locator('[data-slot="question-option"]').first().click()
  157. await dock.getByRole("button", { name: /submit/i }).click()
  158. await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
  159. await expect(page.locator(promptSelector)).toBeVisible()
  160. })
  161. })
  162. })
  163. test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
  164. await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
  165. await gotoSession(session.id)
  166. await setAutoAccept(page, false)
  167. await withMockPermission(
  168. page,
  169. {
  170. id: "per_e2e_once",
  171. sessionID: session.id,
  172. permission: "bash",
  173. patterns: ["/tmp/opencode-e2e-perm-once"],
  174. metadata: { description: "Need permission for command" },
  175. },
  176. undefined,
  177. async () => {
  178. await page.goto(page.url())
  179. await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
  180. await expect(page.locator(promptSelector)).toHaveCount(0)
  181. await clearPermissionDock(page, /allow once/i)
  182. await page.goto(page.url())
  183. await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
  184. await expect(page.locator(promptSelector)).toBeVisible()
  185. },
  186. )
  187. })
  188. })
  189. test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
  190. await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
  191. await gotoSession(session.id)
  192. await setAutoAccept(page, false)
  193. await withMockPermission(
  194. page,
  195. {
  196. id: "per_e2e_reject",
  197. sessionID: session.id,
  198. permission: "bash",
  199. patterns: ["/tmp/opencode-e2e-perm-reject"],
  200. },
  201. undefined,
  202. async () => {
  203. await page.goto(page.url())
  204. await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
  205. await expect(page.locator(promptSelector)).toHaveCount(0)
  206. await clearPermissionDock(page, /deny/i)
  207. await page.goto(page.url())
  208. await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
  209. await expect(page.locator(promptSelector)).toBeVisible()
  210. },
  211. )
  212. })
  213. })
  214. test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
  215. await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
  216. await gotoSession(session.id)
  217. await setAutoAccept(page, false)
  218. await withMockPermission(
  219. page,
  220. {
  221. id: "per_e2e_always",
  222. sessionID: session.id,
  223. permission: "bash",
  224. patterns: ["/tmp/opencode-e2e-perm-always"],
  225. metadata: { description: "Need permission for command" },
  226. },
  227. undefined,
  228. async () => {
  229. await page.goto(page.url())
  230. await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
  231. await expect(page.locator(promptSelector)).toHaveCount(0)
  232. await clearPermissionDock(page, /allow always/i)
  233. await page.goto(page.url())
  234. await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
  235. await expect(page.locator(promptSelector)).toBeVisible()
  236. },
  237. )
  238. })
  239. })
  240. test("child session question request blocks parent dock and unblocks after submit", async ({
  241. page,
  242. sdk,
  243. gotoSession,
  244. }) => {
  245. await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
  246. await gotoSession(session.id)
  247. const child = await sdk.session
  248. .create({
  249. title: "e2e composer dock child question",
  250. parentID: session.id,
  251. })
  252. .then((r) => r.data)
  253. if (!child?.id) throw new Error("Child session create did not return an id")
  254. try {
  255. await withDockSeed(sdk, child.id, async () => {
  256. await seedSessionQuestion(sdk, {
  257. sessionID: child.id,
  258. questions: [
  259. {
  260. header: "Child input",
  261. question: "Pick one child option",
  262. options: [
  263. { label: "Continue", description: "Continue child" },
  264. { label: "Stop", description: "Stop child" },
  265. ],
  266. },
  267. ],
  268. })
  269. const dock = page.locator(questionDockSelector)
  270. await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
  271. await expect(page.locator(promptSelector)).toHaveCount(0)
  272. await dock.locator('[data-slot="question-option"]').first().click()
  273. await dock.getByRole("button", { name: /submit/i }).click()
  274. await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
  275. await expect(page.locator(promptSelector)).toBeVisible()
  276. })
  277. } finally {
  278. await cleanupSession({ sdk, sessionID: child.id })
  279. }
  280. })
  281. })
  282. test("child session permission request blocks parent dock and supports allow once", async ({
  283. page,
  284. sdk,
  285. gotoSession,
  286. }) => {
  287. await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
  288. await gotoSession(session.id)
  289. await setAutoAccept(page, false)
  290. const child = await sdk.session
  291. .create({
  292. title: "e2e composer dock child permission",
  293. parentID: session.id,
  294. })
  295. .then((r) => r.data)
  296. if (!child?.id) throw new Error("Child session create did not return an id")
  297. try {
  298. await withMockPermission(
  299. page,
  300. {
  301. id: "per_e2e_child",
  302. sessionID: child.id,
  303. permission: "bash",
  304. patterns: ["/tmp/opencode-e2e-perm-child"],
  305. metadata: { description: "Need child permission" },
  306. },
  307. { child },
  308. async () => {
  309. await page.goto(page.url())
  310. const dock = page.locator(permissionDockSelector)
  311. await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
  312. await expect(page.locator(promptSelector)).toHaveCount(0)
  313. await clearPermissionDock(page, /allow once/i)
  314. await page.goto(page.url())
  315. await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
  316. await expect(page.locator(promptSelector)).toBeVisible()
  317. },
  318. )
  319. } finally {
  320. await cleanupSession({ sdk, sessionID: child.id })
  321. }
  322. })
  323. })
  324. test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
  325. await withDockSession(sdk, "e2e composer dock todo", async (session) => {
  326. await withDockSeed(sdk, session.id, async () => {
  327. await gotoSession(session.id)
  328. await seedSessionTodos(sdk, {
  329. sessionID: session.id,
  330. todos: [
  331. { content: "first task", status: "pending", priority: "high" },
  332. { content: "second task", status: "in_progress", priority: "medium" },
  333. ],
  334. })
  335. await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
  336. await expect(page.locator(sessionTodoListSelector)).toBeVisible()
  337. await page.locator(sessionTodoToggleButtonSelector).click()
  338. await expect(page.locator(sessionTodoListSelector)).toBeHidden()
  339. await page.locator(sessionTodoToggleButtonSelector).click()
  340. await expect(page.locator(sessionTodoListSelector)).toBeVisible()
  341. await seedSessionTodos(sdk, {
  342. sessionID: session.id,
  343. todos: [
  344. { content: "first task", status: "completed", priority: "high" },
  345. { content: "second task", status: "cancelled", priority: "medium" },
  346. ],
  347. })
  348. await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
  349. })
  350. })
  351. })
  352. test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
  353. await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
  354. await withDockSeed(sdk, session.id, async () => {
  355. await gotoSession(session.id)
  356. await seedSessionQuestion(sdk, {
  357. sessionID: session.id,
  358. questions: [
  359. {
  360. header: "Need input",
  361. question: "Pick one option",
  362. options: [{ label: "Continue", description: "Continue now" }],
  363. },
  364. ],
  365. })
  366. await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
  367. await expect(page.locator(promptSelector)).toHaveCount(0)
  368. await page.locator("main").click({ position: { x: 5, y: 5 } })
  369. await page.keyboard.type("abc")
  370. await expect(page.locator(promptSelector)).toHaveCount(0)
  371. })
  372. })
  373. })