session-composer-dock.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. import { test, expect } from "../fixtures"
  2. import {
  3. composerEvent,
  4. type ComposerDriverState,
  5. type ComposerProbeState,
  6. type ComposerWindow,
  7. } from "../../src/testing/session-composer"
  8. import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
  9. import {
  10. permissionDockSelector,
  11. promptSelector,
  12. questionDockSelector,
  13. sessionComposerDockSelector,
  14. sessionTodoToggleButtonSelector,
  15. } from "../selectors"
  16. type Sdk = Parameters<typeof clearSessionDockSeed>[0]
  17. type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
  18. async function withDockSession<T>(
  19. sdk: Sdk,
  20. title: string,
  21. fn: (session: { id: string; title: string }) => Promise<T>,
  22. opts?: { permission?: PermissionRule[] },
  23. ) {
  24. const session = await sdk.session
  25. .create(opts?.permission ? { title, permission: opts.permission } : { title })
  26. .then((r) => r.data)
  27. if (!session?.id) throw new Error("Session create did not return an id")
  28. try {
  29. return await fn(session)
  30. } finally {
  31. await cleanupSession({ sdk, sessionID: session.id })
  32. }
  33. }
  34. test.setTimeout(120_000)
  35. async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
  36. try {
  37. return await fn()
  38. } finally {
  39. await clearSessionDockSeed(sdk, sessionID).catch(() => undefined)
  40. }
  41. }
  42. async function clearPermissionDock(page: any, label: RegExp) {
  43. const dock = page.locator(permissionDockSelector)
  44. await expect(dock).toBeVisible()
  45. await dock.getByRole("button", { name: label }).click()
  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 expectQuestionBlocked(page: any) {
  56. await expect(page.locator(questionDockSelector)).toBeVisible()
  57. await expect(page.locator(promptSelector)).toHaveCount(0)
  58. }
  59. async function expectQuestionOpen(page: any) {
  60. await expect(page.locator(questionDockSelector)).toHaveCount(0)
  61. await expect(page.locator(promptSelector)).toBeVisible()
  62. }
  63. async function expectPermissionBlocked(page: any) {
  64. await expect(page.locator(permissionDockSelector)).toBeVisible()
  65. await expect(page.locator(promptSelector)).toHaveCount(0)
  66. }
  67. async function expectPermissionOpen(page: any) {
  68. await expect(page.locator(permissionDockSelector)).toHaveCount(0)
  69. await expect(page.locator(promptSelector)).toBeVisible()
  70. }
  71. async function todoDock(page: any, sessionID: string) {
  72. await page.addInitScript(() => {
  73. const win = window as ComposerWindow
  74. win.__opencode_e2e = {
  75. ...win.__opencode_e2e,
  76. composer: {
  77. enabled: true,
  78. sessions: {},
  79. },
  80. }
  81. })
  82. const write = async (driver: ComposerDriverState | undefined) => {
  83. await page.evaluate(
  84. (input) => {
  85. const win = window as ComposerWindow
  86. const composer = win.__opencode_e2e?.composer
  87. if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
  88. composer.sessions ??= {}
  89. const prev = composer.sessions[input.sessionID] ?? {}
  90. if (!input.driver) {
  91. if (!prev.probe) {
  92. delete composer.sessions[input.sessionID]
  93. } else {
  94. composer.sessions[input.sessionID] = { probe: prev.probe }
  95. }
  96. } else {
  97. composer.sessions[input.sessionID] = {
  98. ...prev,
  99. driver: input.driver,
  100. }
  101. }
  102. window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } }))
  103. },
  104. { event: composerEvent, sessionID, driver },
  105. )
  106. }
  107. const read = () =>
  108. page.evaluate((sessionID) => {
  109. const win = window as ComposerWindow
  110. return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
  111. }, sessionID) as Promise<ComposerProbeState | null>
  112. const api = {
  113. async clear() {
  114. await write(undefined)
  115. return api
  116. },
  117. async open(todos: NonNullable<ComposerDriverState["todos"]>) {
  118. await write({ live: true, todos })
  119. return api
  120. },
  121. async finish(todos: NonNullable<ComposerDriverState["todos"]>) {
  122. await write({ live: false, todos })
  123. return api
  124. },
  125. async expectOpen(states: ComposerProbeState["states"]) {
  126. await expect.poll(read, { timeout: 10_000 }).toMatchObject({
  127. mounted: true,
  128. collapsed: false,
  129. hidden: false,
  130. count: states.length,
  131. states,
  132. })
  133. return api
  134. },
  135. async expectCollapsed(states: ComposerProbeState["states"]) {
  136. await expect.poll(read, { timeout: 10_000 }).toMatchObject({
  137. mounted: true,
  138. collapsed: true,
  139. hidden: true,
  140. count: states.length,
  141. states,
  142. })
  143. return api
  144. },
  145. async expectClosed() {
  146. await expect.poll(read, { timeout: 10_000 }).toMatchObject({ mounted: false })
  147. return api
  148. },
  149. async collapse() {
  150. await page.locator(sessionTodoToggleButtonSelector).click()
  151. return api
  152. },
  153. async expand() {
  154. await page.locator(sessionTodoToggleButtonSelector).click()
  155. return api
  156. },
  157. }
  158. return api
  159. }
  160. async function withMockPermission<T>(
  161. page: any,
  162. request: {
  163. id: string
  164. sessionID: string
  165. permission: string
  166. patterns: string[]
  167. metadata?: Record<string, unknown>
  168. always?: string[]
  169. },
  170. opts: { child?: any } | undefined,
  171. fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
  172. ) {
  173. let pending = [
  174. {
  175. ...request,
  176. always: request.always ?? ["*"],
  177. metadata: request.metadata ?? {},
  178. },
  179. ]
  180. const list = async (route: any) => {
  181. await route.fulfill({
  182. status: 200,
  183. contentType: "application/json",
  184. body: JSON.stringify(pending),
  185. })
  186. }
  187. const reply = async (route: any) => {
  188. const url = new URL(route.request().url())
  189. const id = url.pathname.split("/").pop()
  190. pending = pending.filter((item) => item.id !== id)
  191. await route.fulfill({
  192. status: 200,
  193. contentType: "application/json",
  194. body: JSON.stringify(true),
  195. })
  196. }
  197. await page.route("**/permission", list)
  198. await page.route("**/session/*/permissions/*", reply)
  199. const sessionList = opts?.child
  200. ? async (route: any) => {
  201. const res = await route.fetch()
  202. const json = await res.json()
  203. const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
  204. if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
  205. await route.fulfill({
  206. status: res.status(),
  207. headers: res.headers(),
  208. contentType: "application/json",
  209. body: JSON.stringify(json),
  210. })
  211. }
  212. : undefined
  213. if (sessionList) await page.route("**/session?*", sessionList)
  214. const state = {
  215. async resolved() {
  216. await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0)
  217. },
  218. }
  219. try {
  220. return await fn(state)
  221. } finally {
  222. await page.unroute("**/permission", list)
  223. await page.unroute("**/session/*/permissions/*", reply)
  224. if (sessionList) await page.unroute("**/session?*", sessionList)
  225. }
  226. }
  227. test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
  228. await withDockSession(sdk, "e2e composer dock default", async (session) => {
  229. await gotoSession(session.id)
  230. await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
  231. await expect(page.locator(promptSelector)).toBeVisible()
  232. await expect(page.locator(questionDockSelector)).toHaveCount(0)
  233. await expect(page.locator(permissionDockSelector)).toHaveCount(0)
  234. await page.locator(promptSelector).click()
  235. await expect(page.locator(promptSelector)).toBeFocused()
  236. })
  237. })
  238. test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
  239. await gotoSession()
  240. const button = page.locator('[data-action="prompt-permissions"]').first()
  241. await expect(button).toBeVisible()
  242. await expect(button).toHaveAttribute("aria-pressed", "false")
  243. await setAutoAccept(page, true)
  244. await setAutoAccept(page, false)
  245. })
  246. test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
  247. await withDockSession(sdk, "e2e composer dock question", async (session) => {
  248. await withDockSeed(sdk, session.id, async () => {
  249. await gotoSession(session.id)
  250. await seedSessionQuestion(sdk, {
  251. sessionID: session.id,
  252. questions: [
  253. {
  254. header: "Need input",
  255. question: "Pick one option",
  256. options: [
  257. { label: "Continue", description: "Continue now" },
  258. { label: "Stop", description: "Stop here" },
  259. ],
  260. },
  261. ],
  262. })
  263. const dock = page.locator(questionDockSelector)
  264. await expectQuestionBlocked(page)
  265. await dock.locator('[data-slot="question-option"]').first().click()
  266. await dock.getByRole("button", { name: /submit/i }).click()
  267. await expectQuestionOpen(page)
  268. })
  269. })
  270. })
  271. test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
  272. await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
  273. await gotoSession(session.id)
  274. await setAutoAccept(page, false)
  275. await withMockPermission(
  276. page,
  277. {
  278. id: "per_e2e_once",
  279. sessionID: session.id,
  280. permission: "bash",
  281. patterns: ["/tmp/opencode-e2e-perm-once"],
  282. metadata: { description: "Need permission for command" },
  283. },
  284. undefined,
  285. async (state) => {
  286. await page.goto(page.url())
  287. await expectPermissionBlocked(page)
  288. await clearPermissionDock(page, /allow once/i)
  289. await state.resolved()
  290. await page.goto(page.url())
  291. await expectPermissionOpen(page)
  292. },
  293. )
  294. })
  295. })
  296. test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
  297. await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
  298. await gotoSession(session.id)
  299. await setAutoAccept(page, false)
  300. await withMockPermission(
  301. page,
  302. {
  303. id: "per_e2e_reject",
  304. sessionID: session.id,
  305. permission: "bash",
  306. patterns: ["/tmp/opencode-e2e-perm-reject"],
  307. },
  308. undefined,
  309. async (state) => {
  310. await page.goto(page.url())
  311. await expectPermissionBlocked(page)
  312. await clearPermissionDock(page, /deny/i)
  313. await state.resolved()
  314. await page.goto(page.url())
  315. await expectPermissionOpen(page)
  316. },
  317. )
  318. })
  319. })
  320. test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
  321. await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
  322. await gotoSession(session.id)
  323. await setAutoAccept(page, false)
  324. await withMockPermission(
  325. page,
  326. {
  327. id: "per_e2e_always",
  328. sessionID: session.id,
  329. permission: "bash",
  330. patterns: ["/tmp/opencode-e2e-perm-always"],
  331. metadata: { description: "Need permission for command" },
  332. },
  333. undefined,
  334. async (state) => {
  335. await page.goto(page.url())
  336. await expectPermissionBlocked(page)
  337. await clearPermissionDock(page, /allow always/i)
  338. await state.resolved()
  339. await page.goto(page.url())
  340. await expectPermissionOpen(page)
  341. },
  342. )
  343. })
  344. })
  345. test("child session question request blocks parent dock and unblocks after submit", async ({
  346. page,
  347. sdk,
  348. gotoSession,
  349. }) => {
  350. await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
  351. await gotoSession(session.id)
  352. const child = await sdk.session
  353. .create({
  354. title: "e2e composer dock child question",
  355. parentID: session.id,
  356. })
  357. .then((r) => r.data)
  358. if (!child?.id) throw new Error("Child session create did not return an id")
  359. try {
  360. await withDockSeed(sdk, child.id, async () => {
  361. await seedSessionQuestion(sdk, {
  362. sessionID: child.id,
  363. questions: [
  364. {
  365. header: "Child input",
  366. question: "Pick one child option",
  367. options: [
  368. { label: "Continue", description: "Continue child" },
  369. { label: "Stop", description: "Stop child" },
  370. ],
  371. },
  372. ],
  373. })
  374. const dock = page.locator(questionDockSelector)
  375. await expectQuestionBlocked(page)
  376. await dock.locator('[data-slot="question-option"]').first().click()
  377. await dock.getByRole("button", { name: /submit/i }).click()
  378. await expectQuestionOpen(page)
  379. })
  380. } finally {
  381. await cleanupSession({ sdk, sessionID: child.id })
  382. }
  383. })
  384. })
  385. test("child session permission request blocks parent dock and supports allow once", async ({
  386. page,
  387. sdk,
  388. gotoSession,
  389. }) => {
  390. await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
  391. await gotoSession(session.id)
  392. await setAutoAccept(page, false)
  393. const child = await sdk.session
  394. .create({
  395. title: "e2e composer dock child permission",
  396. parentID: session.id,
  397. })
  398. .then((r) => r.data)
  399. if (!child?.id) throw new Error("Child session create did not return an id")
  400. try {
  401. await withMockPermission(
  402. page,
  403. {
  404. id: "per_e2e_child",
  405. sessionID: child.id,
  406. permission: "bash",
  407. patterns: ["/tmp/opencode-e2e-perm-child"],
  408. metadata: { description: "Need child permission" },
  409. },
  410. { child },
  411. async (state) => {
  412. await page.goto(page.url())
  413. await expectPermissionBlocked(page)
  414. await clearPermissionDock(page, /allow once/i)
  415. await state.resolved()
  416. await page.goto(page.url())
  417. await expectPermissionOpen(page)
  418. },
  419. )
  420. } finally {
  421. await cleanupSession({ sdk, sessionID: child.id })
  422. }
  423. })
  424. })
  425. test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
  426. await withDockSession(sdk, "e2e composer dock todo", async (session) => {
  427. const dock = await todoDock(page, session.id)
  428. await gotoSession(session.id)
  429. await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
  430. try {
  431. await dock.open([
  432. { content: "first task", status: "pending", priority: "high" },
  433. { content: "second task", status: "in_progress", priority: "medium" },
  434. ])
  435. await dock.expectOpen(["pending", "in_progress"])
  436. await dock.collapse()
  437. await dock.expectCollapsed(["pending", "in_progress"])
  438. await dock.expand()
  439. await dock.expectOpen(["pending", "in_progress"])
  440. await dock.finish([
  441. { content: "first task", status: "completed", priority: "high" },
  442. { content: "second task", status: "cancelled", priority: "medium" },
  443. ])
  444. await dock.expectClosed()
  445. } finally {
  446. await dock.clear()
  447. }
  448. })
  449. })
  450. test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
  451. await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
  452. await withDockSeed(sdk, session.id, async () => {
  453. await gotoSession(session.id)
  454. await seedSessionQuestion(sdk, {
  455. sessionID: session.id,
  456. questions: [
  457. {
  458. header: "Need input",
  459. question: "Pick one option",
  460. options: [{ label: "Continue", description: "Continue now" }],
  461. },
  462. ],
  463. })
  464. await expectQuestionBlocked(page)
  465. await page.locator("main").click({ position: { x: 5, y: 5 } })
  466. await page.keyboard.type("abc")
  467. await expect(page.locator(promptSelector)).toHaveCount(0)
  468. })
  469. })
  470. })