session-composer-dock.spec.ts 20 KB

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