session-request-docks.spec.ts 8.0 KB

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