session-request-docks.spec.ts 7.9 KB

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