session-composer-dock.spec.ts 13 KB

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