session-model-persistence.spec.ts 13 KB

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