2
0

session-model-persistence.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. import type { Locator, Page } from "@playwright/test"
  2. import { test, expect } from "../fixtures"
  3. import { openSidebar, resolveSlug, setWorkspacesEnabled, waitSession, waitSlug } from "../actions"
  4. import {
  5. promptAgentSelector,
  6. promptModelSelector,
  7. promptVariantSelector,
  8. workspaceItemSelector,
  9. workspaceNewSessionSelector,
  10. } from "../selectors"
  11. import { createSdk, sessionPath } from "../utils"
  12. type Footer = {
  13. agent: string
  14. model: string
  15. variant: string
  16. }
  17. type Probe = {
  18. dir?: string
  19. sessionID?: string
  20. agent?: string
  21. model?: { providerID: string; modelID: string; name?: string }
  22. variant?: string | null
  23. pick?: {
  24. agent?: string
  25. model?: { providerID: string; modelID: string }
  26. variant?: string | null
  27. }
  28. variants?: string[]
  29. models?: Array<{ providerID: string; modelID: string; name: string }>
  30. agents?: Array<{ name: string }>
  31. }
  32. const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
  33. const text = async (locator: Locator) => ((await locator.textContent()) ?? "").trim()
  34. const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
  35. async function probe(page: Page): Promise<Probe | null> {
  36. return page.evaluate(() => {
  37. const win = window as Window & {
  38. __opencode_e2e?: {
  39. model?: {
  40. current?: Probe
  41. }
  42. }
  43. }
  44. return win.__opencode_e2e?.model?.current ?? null
  45. })
  46. }
  47. async function currentModel(page: Page) {
  48. await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).not.toBe(null)
  49. const value = await probe(page).then(modelKey)
  50. if (!value) throw new Error("Failed to resolve current model key")
  51. return value
  52. }
  53. async function waitControl(page: Page, key: "setAgent" | "setModel" | "setVariant") {
  54. await expect
  55. .poll(
  56. () =>
  57. page.evaluate((key) => {
  58. const win = window as Window & {
  59. __opencode_e2e?: {
  60. model?: {
  61. controls?: Record<string, unknown>
  62. }
  63. }
  64. }
  65. return !!win.__opencode_e2e?.model?.controls?.[key]
  66. }, key),
  67. { timeout: 30_000 },
  68. )
  69. .toBe(true)
  70. }
  71. async function pickAgent(page: Page, value: string) {
  72. await waitControl(page, "setAgent")
  73. await page.evaluate((value) => {
  74. const win = window as Window & {
  75. __opencode_e2e?: {
  76. model?: {
  77. controls?: {
  78. setAgent?: (value: string | undefined) => void
  79. }
  80. }
  81. }
  82. }
  83. const fn = win.__opencode_e2e?.model?.controls?.setAgent
  84. if (!fn) throw new Error("Model e2e agent control is not enabled")
  85. fn(value)
  86. }, value)
  87. }
  88. async function pickModel(page: Page, value: { providerID: string; modelID: string }) {
  89. await waitControl(page, "setModel")
  90. await page.evaluate((value) => {
  91. const win = window as Window & {
  92. __opencode_e2e?: {
  93. model?: {
  94. controls?: {
  95. setModel?: (value: { providerID: string; modelID: string } | undefined) => void
  96. }
  97. }
  98. }
  99. }
  100. const fn = win.__opencode_e2e?.model?.controls?.setModel
  101. if (!fn) throw new Error("Model e2e model control is not enabled")
  102. fn(value)
  103. }, value)
  104. }
  105. async function pickVariant(page: Page, value: string) {
  106. await waitControl(page, "setVariant")
  107. await page.evaluate((value) => {
  108. const win = window as Window & {
  109. __opencode_e2e?: {
  110. model?: {
  111. controls?: {
  112. setVariant?: (value: string | undefined) => void
  113. }
  114. }
  115. }
  116. }
  117. const fn = win.__opencode_e2e?.model?.controls?.setVariant
  118. if (!fn) throw new Error("Model e2e variant control is not enabled")
  119. fn(value)
  120. }, value)
  121. }
  122. async function read(page: Page): Promise<Footer> {
  123. return {
  124. agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
  125. model: await text(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()),
  126. variant: await text(page.locator(`${promptVariantSelector} [data-slot="select-select-trigger-value"]`).first()),
  127. }
  128. }
  129. async function waitFooter(page: Page, expected: Partial<Footer>) {
  130. let hit: Footer | null = null
  131. await expect
  132. .poll(
  133. async () => {
  134. const state = await read(page)
  135. const ok = Object.entries(expected).every(([key, value]) => state[key as keyof Footer] === value)
  136. if (ok) hit = state
  137. return ok
  138. },
  139. { timeout: 30_000 },
  140. )
  141. .toBe(true)
  142. if (!hit) throw new Error("Failed to resolve prompt footer state")
  143. return hit
  144. }
  145. async function waitModel(page: Page, value: string) {
  146. await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).toBe(value)
  147. }
  148. async function choose(page: Page, root: string, value: string) {
  149. const select = page.locator(root)
  150. await expect(select).toBeVisible()
  151. await pickAgent(page, value)
  152. }
  153. async function variantCount(page: Page) {
  154. return (await probe(page))?.variants?.length ?? 0
  155. }
  156. async function agents(page: Page) {
  157. return ((await probe(page))?.agents ?? []).map((item) => item.name).filter(Boolean)
  158. }
  159. async function ensureVariant(page: Page, directory: string): Promise<Footer> {
  160. const current = await read(page)
  161. if ((await variantCount(page)) >= 2) return current
  162. const cfg = await createSdk(directory)
  163. .config.get()
  164. .then((x) => x.data)
  165. const visible = new Set(await agents(page))
  166. const entry = Object.entries(cfg?.agent ?? {}).find((item) => {
  167. const value = item[1]
  168. return !!value && typeof value === "object" && "variant" in value && "model" in value && visible.has(item[0])
  169. })
  170. const name = entry?.[0]
  171. test.skip(!name, "no agent with alternate variants available")
  172. if (!name) return current
  173. await choose(page, promptAgentSelector, name)
  174. await expect.poll(() => variantCount(page), { timeout: 30_000 }).toBeGreaterThanOrEqual(2)
  175. return waitFooter(page, { agent: name })
  176. }
  177. async function chooseDifferentVariant(page: Page): Promise<Footer> {
  178. const current = await read(page)
  179. const next = (await probe(page))?.variants?.find((item) => item !== current.variant)
  180. if (!next) throw new Error("Current model has no alternate variant to select")
  181. await pickVariant(page, next)
  182. return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
  183. }
  184. async function chooseOtherModel(page: Page, skip: string[] = []): Promise<Footer> {
  185. const current = await currentModel(page)
  186. const next = (await probe(page))?.models?.find((item) => {
  187. const key = `${item.providerID}:${item.modelID}`
  188. return key !== current && !skip.includes(key)
  189. })
  190. if (!next) throw new Error("Failed to choose a different model")
  191. await pickModel(page, { providerID: next.providerID, modelID: next.modelID })
  192. await expect.poll(async () => (await read(page)).model, { timeout: 30_000 }).toBe(next.name)
  193. return read(page)
  194. }
  195. async function goto(page: Page, directory: string, sessionID?: string) {
  196. await page.goto(sessionPath(directory, sessionID))
  197. await waitSession(page, { directory, sessionID })
  198. }
  199. async function submit(project: Parameters<typeof test>[0]["project"], value: string) {
  200. return project.prompt(value)
  201. }
  202. async function createWorkspace(page: Page, root: string, seen: string[]) {
  203. await openSidebar(page)
  204. await page.getByRole("button", { name: "New workspace" }).first().click()
  205. const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
  206. await waitSession(page, { directory: next.directory })
  207. return next
  208. }
  209. async function waitWorkspace(page: Page, slug: string) {
  210. await openSidebar(page)
  211. await expect
  212. .poll(
  213. async () => {
  214. const item = page.locator(workspaceItemSelector(slug)).first()
  215. try {
  216. await item.hover({ timeout: 500 })
  217. return true
  218. } catch {
  219. return false
  220. }
  221. },
  222. { timeout: 60_000 },
  223. )
  224. .toBe(true)
  225. }
  226. async function newWorkspaceSession(page: Page, slug: string) {
  227. await waitWorkspace(page, slug)
  228. const item = page.locator(workspaceItemSelector(slug)).first()
  229. await item.hover()
  230. const button = page.locator(workspaceNewSessionSelector(slug)).first()
  231. await expect(button).toBeVisible()
  232. await button.click({ force: true })
  233. const next = await resolveSlug(await waitSlug(page))
  234. return waitSession(page, { directory: next.directory }).then((item) => item.directory)
  235. }
  236. test("session model restore per session without leaking into new sessions", async ({ page, project }) => {
  237. await page.setViewportSize({ width: 1440, height: 900 })
  238. await project.open()
  239. await project.gotoSession()
  240. const firstState = await chooseOtherModel(page)
  241. const firstKey = await currentModel(page)
  242. const first = await submit(project, `session variant ${Date.now()}`)
  243. await page.reload()
  244. await waitSession(page, { directory: project.directory, sessionID: first })
  245. await waitFooter(page, firstState)
  246. await project.gotoSession()
  247. const fresh = await read(page)
  248. expect(fresh.model).not.toBe(firstState.model)
  249. const secondState = await chooseOtherModel(page, [firstKey])
  250. const second = await submit(project, `session model ${Date.now()}`)
  251. await goto(page, project.directory, first)
  252. await waitFooter(page, firstState)
  253. await goto(page, project.directory, second)
  254. await waitFooter(page, secondState)
  255. await project.gotoSession()
  256. await page.reload()
  257. await waitSession(page, { directory: project.directory })
  258. await waitFooter(page, fresh)
  259. })
  260. test("session model restore across workspaces", async ({ page, project }) => {
  261. await page.setViewportSize({ width: 1440, height: 900 })
  262. await project.open()
  263. const root = project.directory
  264. await project.gotoSession()
  265. const firstState = await chooseOtherModel(page)
  266. const firstKey = await currentModel(page)
  267. const first = await submit(project, `root session ${Date.now()}`)
  268. await openSidebar(page)
  269. await setWorkspacesEnabled(page, project.slug, true)
  270. const one = await createWorkspace(page, project.slug, [])
  271. const oneDir = await newWorkspaceSession(page, one.slug)
  272. project.trackDirectory(oneDir)
  273. const secondState = await chooseOtherModel(page, [firstKey])
  274. const secondKey = await currentModel(page)
  275. const second = await submit(project, `workspace one ${Date.now()}`)
  276. const two = await createWorkspace(page, project.slug, [one.slug])
  277. const twoDir = await newWorkspaceSession(page, two.slug)
  278. project.trackDirectory(twoDir)
  279. const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
  280. const third = await submit(project, `workspace two ${Date.now()}`)
  281. await goto(page, root, first)
  282. await waitFooter(page, firstState)
  283. await goto(page, oneDir, second)
  284. await waitFooter(page, secondState)
  285. await goto(page, twoDir, third)
  286. await waitFooter(page, thirdState)
  287. await goto(page, root, first)
  288. await waitFooter(page, firstState)
  289. })
  290. test("variant preserved when switching agent modes", async ({ page, project }) => {
  291. await page.setViewportSize({ width: 1440, height: 900 })
  292. await project.open()
  293. await project.gotoSession()
  294. await ensureVariant(page, project.directory)
  295. const updated = await chooseDifferentVariant(page)
  296. const available = await agents(page)
  297. const other = available.find((name) => name !== updated.agent)
  298. test.skip(!other, "only one agent available")
  299. if (!other) return
  300. await choose(page, promptAgentSelector, other)
  301. await waitFooter(page, { agent: other, variant: updated.variant })
  302. await choose(page, promptAgentSelector, updated.agent)
  303. await waitFooter(page, { agent: updated.agent, variant: updated.variant })
  304. })