remote-session-settings.spec.ts 11 KB

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