session-request-docks.spec.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. import { base64Encode } from "@opencode-ai/core/util/encode"
  2. import { expect, test, type Page } from "@playwright/test"
  3. import { mockOpenCodeServer } from "../utils/mock-server"
  4. import { installSseTransport } from "../utils/sse-transport"
  5. import { expectSessionTitle } from "../utils/waits"
  6. const directory = "C:/OpenCode/RequestDocks"
  7. const projectID = "proj_request_docks"
  8. const sessionID = "ses_request_docks"
  9. const title = "Request dock regression"
  10. test("shows a pending question dock", async ({ page }) => {
  11. await mockServer(page, {
  12. forms: [
  13. {
  14. id: "frm_question_request",
  15. sessionID,
  16. title: "Questions",
  17. metadata: { kind: "question" },
  18. fields: [
  19. {
  20. key: "q0",
  21. type: "string",
  22. title: "Implementation",
  23. description: "Which implementation should be used?",
  24. options: [
  25. { value: "minimal", label: "Minimal", description: "Use the smallest correct change" },
  26. { value: "extended", label: "Extended", description: "Include additional behavior" },
  27. ],
  28. custom: true,
  29. },
  30. ],
  31. },
  32. ],
  33. })
  34. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
  35. await expectSessionTitle(page, title)
  36. const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
  37. await expect(question).toBeVisible()
  38. await expect(question.getByText("Which implementation should be used?")).toBeVisible()
  39. await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
  40. await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
  41. await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
  42. const rejectRequests: string[] = []
  43. page.on("request", (request) => {
  44. if (request.method() !== "POST") return
  45. if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`)
  46. rejectRequests.push(request.url())
  47. })
  48. await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
  49. await expect(question).toBeVisible()
  50. await expect(question.getByText("Which implementation should be used?")).toBeVisible()
  51. await expect(question.getByText("Select one answer")).toBeHidden()
  52. await expect(question.getByRole("radio", { name: /Minimal/ })).toBeHidden()
  53. await expect(question.getByRole("radio", { name: /Extended/ })).toBeHidden()
  54. await expect(question.getByRole("button", { name: "Dismiss" })).toBeVisible()
  55. await expect(question.getByRole("button", { name: "Submit" })).toBeVisible()
  56. await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0)
  57. expect(rejectRequests).toEqual([])
  58. await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
  59. await expect(question).toBeVisible()
  60. await expect(question.getByText("Which implementation should be used?")).toBeVisible()
  61. await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
  62. expect(rejectRequests).toEqual([])
  63. await question.getByRole("radio", { name: /Minimal/ }).click()
  64. const reply = page.waitForRequest(
  65. (request) =>
  66. request.method() === "POST" &&
  67. new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/reply`,
  68. )
  69. await question.getByRole("button", { name: "Submit" }).click()
  70. expect((await reply).postDataJSON()).toEqual({ answer: { q0: "minimal" } })
  71. })
  72. test("shows a pending permission dock", async ({ page }) => {
  73. await mockServer(page, {
  74. permissions: [
  75. {
  76. id: "permission-request",
  77. sessionID,
  78. permission: "bash",
  79. patterns: ["git status", "git diff"],
  80. metadata: {},
  81. always: [],
  82. },
  83. ],
  84. })
  85. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
  86. await expectSessionTitle(page, title)
  87. const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
  88. await expect(permission).toBeVisible()
  89. await expect(permission.getByText("git status")).toBeVisible()
  90. await expect(permission.getByText("git diff")).toBeVisible()
  91. await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveCount(3)
  92. await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
  93. const reply = page.waitForRequest((request) => request.method() === "POST")
  94. await permission.getByRole("button", { name: "Allow once" }).click()
  95. const request = await reply
  96. expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
  97. expect(request.postDataJSON()).toEqual({ reply: "once" })
  98. })
  99. test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
  100. const transport = await installSseTransport(page, {
  101. server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
  102. retry: 20,
  103. })
  104. await mockServer(page, { forms: [] })
  105. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
  106. await transport.waitForConnection()
  107. await expectSessionTitle(page, title)
  108. const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
  109. const draft = "keep the caret at the end"
  110. await editor.fill(draft)
  111. await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
  112. for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft")
  113. const cursor = draft.length - 4
  114. await expect
  115. .poll(() =>
  116. editor.evaluate((element) => {
  117. const selection = window.getSelection()
  118. if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1
  119. const range = selection.getRangeAt(0).cloneRange()
  120. range.selectNodeContents(element)
  121. range.setEnd(selection.anchorNode!, selection.anchorOffset)
  122. return range.toString().length
  123. }),
  124. )
  125. .toBe(cursor)
  126. await transport.send({
  127. directory,
  128. payload: {
  129. type: "form.created",
  130. properties: {
  131. form: {
  132. id: "frm_question_caret",
  133. sessionID,
  134. title: "Questions",
  135. metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } },
  136. fields: [
  137. {
  138. key: "q0",
  139. type: "string",
  140. title: "Continue",
  141. description: "Continue?",
  142. options: [{ value: "yes", label: "Yes", description: "Continue the session" }],
  143. custom: true,
  144. },
  145. ],
  146. },
  147. },
  148. },
  149. })
  150. const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
  151. await expect(question).toBeVisible()
  152. await expect(editor).toHaveCount(0)
  153. await transport.send({
  154. directory,
  155. payload: {
  156. type: "form.cancelled",
  157. properties: { sessionID, id: "frm_question_caret" },
  158. },
  159. })
  160. await expect(question).toHaveCount(0)
  161. await expect(editor).toBeVisible()
  162. await page.keyboard.press("x")
  163. await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`)
  164. })
  165. async function mockServer(
  166. page: Page,
  167. requests: {
  168. permissions?: unknown[] | (() => unknown[])
  169. questions?: unknown[] | (() => unknown[])
  170. forms?: unknown[] | (() => unknown[])
  171. sessionStatus?: Record<string, unknown>
  172. },
  173. ) {
  174. await mockOpenCodeServer(page, {
  175. protocol: "v2",
  176. directory,
  177. project: {
  178. id: projectID,
  179. worktree: directory,
  180. vcs: "git",
  181. name: "request-docks",
  182. time: { created: 1700000000000, updated: 1700000000000 },
  183. sandboxes: [],
  184. },
  185. provider: {
  186. all: [
  187. {
  188. id: "opencode",
  189. name: "OpenCode",
  190. models: {
  191. "claude-opus-4-6": {
  192. id: "claude-opus-4-6",
  193. name: "Claude Opus 4.6",
  194. limit: { context: 200_000 },
  195. },
  196. },
  197. },
  198. ],
  199. connected: ["opencode"],
  200. default: { providerID: "opencode", modelID: "claude-opus-4-6" },
  201. },
  202. sessions: [
  203. {
  204. id: sessionID,
  205. slug: "request-docks",
  206. projectID,
  207. directory,
  208. title,
  209. version: "dev",
  210. time: { created: 1700000000000, updated: 1700000000000 },
  211. },
  212. ],
  213. pageMessages: () => ({ items: [] }),
  214. permissions: requests.permissions,
  215. questions: requests.questions,
  216. forms: requests.forms,
  217. sessionStatus: requests.sessionStatus,
  218. })
  219. await page.addInitScript(() => {
  220. localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
  221. })
  222. }