remote-session-settings.spec.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. import { base64Encode } from "@opencode-ai/core/util/encode"
  2. import { expect, test, type Page, type Route } from "@playwright/test"
  3. import { installSseTransport } from "../utils/sse-transport"
  4. const serverA = "http://127.0.0.1:4096"
  5. const serverB = "http://127.0.0.1:4097"
  6. const directoryA = "C:/server-a"
  7. const directoryB = "/home/server-b"
  8. const sessionA = session("ses_server_a", directoryA, "Server A session")
  9. const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id }
  10. const sessionB = session("ses_server_b", directoryB, "Server B session")
  11. test("session settings use the remote server context", async ({ page }) => {
  12. const permissionRequests: string[] = []
  13. await mockServers(page, permissionRequests)
  14. await configureServers(page)
  15. await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
  16. await expect(page.getByText(sessionB.title).first()).toBeVisible()
  17. await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
  18. const dialog = page.locator(".settings-v2-dialog")
  19. const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
  20. const input = autoAccept.getByRole("switch")
  21. await expect(autoAccept).toBeVisible()
  22. await expect(input).toBeEnabled()
  23. permissionRequests.length = 0
  24. await autoAccept.locator('[data-slot="switch-control"]').click()
  25. await expect(input).toBeChecked()
  26. await expect
  27. .poll(() =>
  28. permissionRequests.some((request) => {
  29. const url = new URL(request)
  30. return url.origin === serverB && url.searchParams.get("directory") === directoryB
  31. }),
  32. )
  33. .toBe(true)
  34. expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
  35. await dialog.getByRole("tab", { name: "Models" }).click()
  36. await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
  37. await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
  38. })
  39. test("auto-accept responds for an unfocused server session", async ({ page }) => {
  40. const permissionRequests: string[] = []
  41. const permissionResponses: PermissionResponse[] = []
  42. const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
  43. server: serverA,
  44. retry: 20,
  45. })
  46. await mockServers(page, permissionRequests, permissionResponses)
  47. await configureServers(page, [
  48. { type: "session", server: serverA, sessionId: sessionA.id },
  49. { type: "session", server: serverB, sessionId: sessionB.id },
  50. ])
  51. const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
  52. await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
  53. await expect(page.getByText(sessionA.title).first()).toBeVisible()
  54. await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
  55. const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
  56. await autoAccept.locator('[data-slot="switch-control"]').click()
  57. await expect(autoAccept.getByRole("switch")).toBeChecked()
  58. await expect
  59. .poll(() =>
  60. permissionRequests.some((request) => {
  61. const url = new URL(request)
  62. return url.origin === serverA && url.searchParams.get("directory") === directoryA
  63. }),
  64. )
  65. .toBe(true)
  66. await page.keyboard.press("Escape")
  67. await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
  68. await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
  69. await expect(page.getByText(sessionB.title).first()).toBeVisible()
  70. await transport.waitForConnection()
  71. await transport.send({
  72. directory: directoryA,
  73. payload: {
  74. id: "event-permission-background-a",
  75. type: "permission.asked",
  76. properties: {
  77. id: "permission-background-a",
  78. sessionID: sessionA.id,
  79. permission: "bash",
  80. patterns: ["git status"],
  81. metadata: {},
  82. always: [],
  83. },
  84. },
  85. })
  86. await expect
  87. .poll(() => permissionResponses)
  88. .toEqual([
  89. {
  90. origin: serverA,
  91. directory: directoryA,
  92. sessionID: sessionA.id,
  93. permissionID: "permission-background-a",
  94. body: { response: "once" },
  95. },
  96. ])
  97. await transport.send({
  98. directory: directoryA,
  99. payload: {
  100. id: "event-permission-background-a-child",
  101. type: "permission.asked",
  102. properties: {
  103. id: "permission-background-a-child",
  104. sessionID: childSessionA.id,
  105. permission: "bash",
  106. patterns: ["git diff"],
  107. metadata: {},
  108. always: [],
  109. },
  110. },
  111. })
  112. await expect
  113. .poll(() => permissionResponses)
  114. .toEqual([
  115. {
  116. origin: serverA,
  117. directory: directoryA,
  118. sessionID: sessionA.id,
  119. permissionID: "permission-background-a",
  120. body: { response: "once" },
  121. },
  122. {
  123. origin: serverA,
  124. directory: directoryA,
  125. sessionID: childSessionA.id,
  126. permissionID: "permission-background-a-child",
  127. body: { response: "once" },
  128. },
  129. ])
  130. })
  131. type PermissionResponse = {
  132. origin: string
  133. directory?: string
  134. sessionID: string
  135. permissionID: string
  136. body: unknown
  137. }
  138. async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
  139. await page.addInitScript(
  140. ({ serverB, tabs }) => {
  141. localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
  142. localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
  143. localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs))
  144. },
  145. { serverB, tabs },
  146. )
  147. }
  148. async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
  149. await page.route("**/*", async (route) => {
  150. const url = new URL(route.request().url())
  151. if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
  152. const remote = url.origin === serverB
  153. const directory = remote ? directoryB : directoryA
  154. const sessions = remote ? [sessionB] : [sessionA, childSessionA]
  155. const requestDirectory = url.searchParams.get("directory")
  156. const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
  157. if (route.request().method() === "POST" && response) {
  158. permissionResponses.push({
  159. origin: url.origin,
  160. directory: requestDirectory ?? undefined,
  161. sessionID: response[1]!,
  162. permissionID: response[2]!,
  163. body: route.request().postDataJSON(),
  164. })
  165. return json(route, true)
  166. }
  167. if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
  168. if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
  169. if (url.pathname === "/global/health") return json(route, { healthy: true })
  170. if (url.pathname === "/session/status") return json(route, {})
  171. if (url.pathname === "/session") return json(route, sessions)
  172. const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
  173. if (current) return json(route, current)
  174. if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
  175. if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
  176. if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
  177. if (url.pathname === "/permission") {
  178. permissionRequests.push(url.toString())
  179. return json(route, [])
  180. }
  181. if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
  182. return json(route, [])
  183. if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
  184. if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
  185. if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
  186. if (url.pathname === "/project" || url.pathname === "/project/current") {
  187. const project = {
  188. id: remote ? sessionB.projectID : "project-server-a",
  189. worktree: directory,
  190. vcs: "git",
  191. time: { created: 1, updated: 1 },
  192. sandboxes: [],
  193. }
  194. return json(route, url.pathname === "/project" ? [project] : project)
  195. }
  196. if (url.pathname === "/path")
  197. return json(route, {
  198. state: directory,
  199. config: directory,
  200. worktree: directory,
  201. directory,
  202. home: directory,
  203. })
  204. if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
  205. return json(route, {})
  206. })
  207. }
  208. function session(id: string, directory: string, title: string) {
  209. return {
  210. id,
  211. slug: id,
  212. projectID: `project-${id}`,
  213. directory,
  214. title,
  215. version: "dev",
  216. time: { created: 1, updated: 1 },
  217. }
  218. }
  219. function provider(id: string) {
  220. const name = id === "server-b" ? "Server B" : "Server A"
  221. return {
  222. all: [
  223. {
  224. id,
  225. name: `${name} Provider`,
  226. models: {
  227. [id]: {
  228. id,
  229. name: `${name} Model`,
  230. family: id,
  231. release_date: "2026-01-01",
  232. limit: { context: 200_000 },
  233. },
  234. },
  235. },
  236. ],
  237. connected: [id],
  238. default: { providerID: id, modelID: id },
  239. }
  240. }
  241. function json(route: Route, body: unknown, status = 200) {
  242. return route.fulfill({
  243. status,
  244. contentType: "application/json",
  245. headers: { "access-control-allow-origin": "*" },
  246. body: JSON.stringify(body),
  247. })
  248. }
  249. function sse(route: Route) {
  250. return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
  251. }