fixtures.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. import { test as base, expect, type Page } from "@playwright/test"
  2. import { ManagedRuntime } from "effect"
  3. import type { E2EWindow } from "../src/testing/terminal"
  4. import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
  5. import { TestLLMServer } from "../../opencode/test/lib/llm-server"
  6. import { startBackend } from "./backend"
  7. import {
  8. healthPhase,
  9. cleanupSession,
  10. cleanupTestProject,
  11. createTestProject,
  12. setHealthPhase,
  13. seedProjects,
  14. sessionIDFromUrl,
  15. waitSlug,
  16. waitSession,
  17. } from "./actions"
  18. import { openaiModel, withMockOpenAI } from "./prompt/mock"
  19. import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
  20. type LLMFixture = {
  21. url: string
  22. push: (...input: (Item | Reply)[]) => Promise<void>
  23. pushMatch: (
  24. match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
  25. ...input: (Item | Reply)[]
  26. ) => Promise<void>
  27. textMatch: (
  28. match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
  29. value: string,
  30. opts?: { usage?: Usage },
  31. ) => Promise<void>
  32. toolMatch: (
  33. match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
  34. name: string,
  35. input: unknown,
  36. ) => Promise<void>
  37. text: (value: string, opts?: { usage?: Usage }) => Promise<void>
  38. tool: (name: string, input: unknown) => Promise<void>
  39. toolHang: (name: string, input: unknown) => Promise<void>
  40. reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
  41. fail: (message?: unknown) => Promise<void>
  42. error: (status: number, body: unknown) => Promise<void>
  43. hang: () => Promise<void>
  44. hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
  45. hits: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
  46. calls: () => Promise<number>
  47. wait: (count: number) => Promise<void>
  48. inputs: () => Promise<Record<string, unknown>[]>
  49. pending: () => Promise<number>
  50. misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
  51. }
  52. export const settingsKey = "settings.v3"
  53. const seedModel = (() => {
  54. const [providerID = "opencode", modelID = "big-pickle"] = (
  55. process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle"
  56. ).split("/")
  57. return {
  58. providerID: providerID || "opencode",
  59. modelID: modelID || "big-pickle",
  60. }
  61. })()
  62. type ProjectHandle = {
  63. directory: string
  64. slug: string
  65. gotoSession: (sessionID?: string) => Promise<void>
  66. trackSession: (sessionID: string, directory?: string) => void
  67. trackDirectory: (directory: string) => void
  68. sdk: ReturnType<typeof createSdk>
  69. }
  70. type ProjectOptions = {
  71. extra?: string[]
  72. model?: { providerID: string; modelID: string }
  73. setup?: (directory: string) => Promise<void>
  74. beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
  75. }
  76. type TestFixtures = {
  77. llm: LLMFixture
  78. sdk: ReturnType<typeof createSdk>
  79. gotoSession: (sessionID?: string) => Promise<void>
  80. withProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
  81. withBackendProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
  82. withMockProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
  83. }
  84. type WorkerFixtures = {
  85. backend: {
  86. url: string
  87. sdk: (directory?: string) => ReturnType<typeof createSdk>
  88. }
  89. directory: string
  90. slug: string
  91. }
  92. export const test = base.extend<TestFixtures, WorkerFixtures>({
  93. backend: [
  94. async ({}, use, workerInfo) => {
  95. const handle = await startBackend(`w${workerInfo.workerIndex}`)
  96. try {
  97. await use({
  98. url: handle.url,
  99. sdk: (directory?: string) => createSdk(directory, handle.url),
  100. })
  101. } finally {
  102. await handle.stop()
  103. }
  104. },
  105. { scope: "worker" },
  106. ],
  107. llm: async ({}, use) => {
  108. const rt = ManagedRuntime.make(TestLLMServer.layer)
  109. try {
  110. const svc = await rt.runPromise(TestLLMServer.asEffect())
  111. await use({
  112. url: svc.url,
  113. push: (...input) => rt.runPromise(svc.push(...input)),
  114. pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
  115. textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
  116. toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
  117. text: (value, opts) => rt.runPromise(svc.text(value, opts)),
  118. tool: (name, input) => rt.runPromise(svc.tool(name, input)),
  119. toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
  120. reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
  121. fail: (message) => rt.runPromise(svc.fail(message)),
  122. error: (status, body) => rt.runPromise(svc.error(status, body)),
  123. hang: () => rt.runPromise(svc.hang),
  124. hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
  125. hits: () => rt.runPromise(svc.hits),
  126. calls: () => rt.runPromise(svc.calls),
  127. wait: (count) => rt.runPromise(svc.wait(count)),
  128. inputs: () => rt.runPromise(svc.inputs),
  129. pending: () => rt.runPromise(svc.pending),
  130. misses: () => rt.runPromise(svc.misses),
  131. })
  132. } finally {
  133. await rt.dispose()
  134. }
  135. },
  136. page: async ({ page }, use) => {
  137. let boundary: string | undefined
  138. setHealthPhase(page, "test")
  139. const consoleHandler = (msg: { text(): string }) => {
  140. const text = msg.text()
  141. if (!text.includes("[e2e:error-boundary]")) return
  142. if (healthPhase(page) === "cleanup") {
  143. console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
  144. return
  145. }
  146. boundary ||= text
  147. console.log(text)
  148. }
  149. const pageErrorHandler = (err: Error) => {
  150. console.log(`[e2e:pageerror] ${err.stack || err.message}`)
  151. }
  152. page.on("console", consoleHandler)
  153. page.on("pageerror", pageErrorHandler)
  154. await use(page)
  155. page.off("console", consoleHandler)
  156. page.off("pageerror", pageErrorHandler)
  157. if (boundary) throw new Error(boundary)
  158. },
  159. directory: [
  160. async ({}, use) => {
  161. const directory = await getWorktree()
  162. await use(directory)
  163. },
  164. { scope: "worker" },
  165. ],
  166. slug: [
  167. async ({ directory }, use) => {
  168. await use(dirSlug(directory))
  169. },
  170. { scope: "worker" },
  171. ],
  172. sdk: async ({ directory }, use) => {
  173. await use(createSdk(directory))
  174. },
  175. gotoSession: async ({ page, directory }, use) => {
  176. await seedStorage(page, { directory })
  177. const gotoSession = async (sessionID?: string) => {
  178. await page.goto(sessionPath(directory, sessionID))
  179. await waitSession(page, { directory, sessionID })
  180. }
  181. await use(gotoSession)
  182. },
  183. withProject: async ({ page }, use) => {
  184. await use((callback, options) => runProject(page, callback, options))
  185. },
  186. withBackendProject: async ({ page, backend }, use) => {
  187. await use((callback, options) =>
  188. runProject(page, callback, { ...options, serverUrl: backend.url, sdk: backend.sdk }),
  189. )
  190. },
  191. withMockProject: async ({ page, llm, backend }, use) => {
  192. await use((callback, options) =>
  193. withMockOpenAI({
  194. serverUrl: backend.url,
  195. llmUrl: llm.url,
  196. fn: () =>
  197. runProject(page, callback, {
  198. ...options,
  199. model: options?.model ?? openaiModel,
  200. serverUrl: backend.url,
  201. sdk: backend.sdk,
  202. }),
  203. }),
  204. )
  205. },
  206. })
  207. async function runProject<T>(
  208. page: Page,
  209. callback: (project: ProjectHandle) => Promise<T>,
  210. options?: ProjectOptions & {
  211. serverUrl?: string
  212. sdk?: (directory?: string) => ReturnType<typeof createSdk>
  213. },
  214. ) {
  215. const url = options?.serverUrl
  216. const root = await createTestProject(url ? { serverUrl: url } : undefined)
  217. const sdk = options?.sdk?.(root) ?? createSdk(root, url)
  218. const sessions = new Map<string, string>()
  219. const dirs = new Set<string>()
  220. await options?.setup?.(root)
  221. await seedStorage(page, {
  222. directory: root,
  223. extra: options?.extra,
  224. model: options?.model,
  225. serverUrl: url,
  226. })
  227. const gotoSession = async (sessionID?: string) => {
  228. await page.goto(sessionPath(root, sessionID))
  229. await waitSession(page, { directory: root, sessionID, serverUrl: url })
  230. const current = sessionIDFromUrl(page.url())
  231. if (current) trackSession(current)
  232. }
  233. const trackSession = (sessionID: string, directory?: string) => {
  234. sessions.set(sessionID, directory ?? root)
  235. }
  236. const trackDirectory = (directory: string) => {
  237. if (directory !== root) dirs.add(directory)
  238. }
  239. try {
  240. await options?.beforeGoto?.({ directory: root, sdk })
  241. await gotoSession()
  242. const slug = await waitSlug(page)
  243. return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory, sdk })
  244. } finally {
  245. setHealthPhase(page, "cleanup")
  246. await Promise.allSettled(
  247. Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory, serverUrl: url })),
  248. )
  249. await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
  250. await cleanupTestProject(root)
  251. setHealthPhase(page, "test")
  252. }
  253. }
  254. async function seedStorage(
  255. page: Page,
  256. input: {
  257. directory: string
  258. extra?: string[]
  259. model?: { providerID: string; modelID: string }
  260. serverUrl?: string
  261. },
  262. ) {
  263. await seedProjects(page, input)
  264. await page.addInitScript((model: { providerID: string; modelID: string }) => {
  265. const win = window as E2EWindow
  266. win.__opencode_e2e = {
  267. ...win.__opencode_e2e,
  268. model: {
  269. enabled: true,
  270. },
  271. prompt: {
  272. enabled: true,
  273. },
  274. terminal: {
  275. enabled: true,
  276. terminals: {},
  277. },
  278. }
  279. localStorage.setItem(
  280. "opencode.global.dat:model",
  281. JSON.stringify({
  282. recent: [model],
  283. user: [],
  284. variant: {},
  285. }),
  286. )
  287. }, input.model ?? seedModel)
  288. }
  289. export { expect }