remote-session-settings.spec.ts 12 KB

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